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
This commit is contained in:
		| @@ -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<Pair<String, String>>? | ||||
|  | ||||
|     /** | ||||
|      * 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 | ||||
|      */ | ||||
|   | ||||
| @@ -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<Addon, AddonAdapter.AddonViewHolder>( | ||||
|     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<Addon>() { | ||||
|         override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|  | ||||
|         override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Applet>) : | ||||
|         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<Applet>) : | ||||
|         view.findNavController().navigate(action) | ||||
|     } | ||||
|  | ||||
|     inner class AppletViewHolder(val binding: CardAppletOptionBinding) : | ||||
|     inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|         lateinit var applet: Applet | ||||
|  | ||||
|   | ||||
| @@ -44,19 +44,20 @@ import org.yuzu.yuzu_emu.utils.GameIconUtils | ||||
|  | ||||
| class GameAdapter(private val activity: AppCompatActivity) : | ||||
|     ListAdapter<Game, GameViewHolder>(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 | ||||
|   | ||||
| @@ -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<GameProperty> | ||||
| ) : | ||||
|     RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() { | ||||
|     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 | ||||
|     } | ||||
| } | ||||
| @@ -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<AddonsFragmentArgs>() | ||||
|  | ||||
|     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 | ||||
|         } | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -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<GameInfoFragmentArgs>() | ||||
|  | ||||
|     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 | ||||
|         } | ||||
| } | ||||
| @@ -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<GamePropertiesFragmentArgs>() | ||||
|  | ||||
|     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<GameProperty>().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) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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>(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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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 | ||||
| ) | ||||
| @@ -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<Addon>()) | ||||
|     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<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 | ||||
|                 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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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<String>? = 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 | ||||
| @@ -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<List<Uri>?>(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<Uri>?) { | ||||
|         _contentToInstall.value = documents | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -23,7 +23,7 @@ class TaskViewModel : ViewModel() { | ||||
|     val cancelled: StateFlow<Boolean> get() = _cancelled | ||||
|     private val _cancelled = MutableStateFlow(false) | ||||
|  | ||||
|     lateinit var task: () -> Any | ||||
|     lateinit var task: suspend () -> Any | ||||
|  | ||||
|     fun clear() { | ||||
|         _result.value = Any() | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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<Uri> -> | ||||
|         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<Uri>) { | ||||
|         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() | ||||
|             } | ||||
|         } | ||||
| } | ||||
|   | ||||
| @@ -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") | ||||
| } | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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<String> | ||||
|  | ||||
|     /** | ||||
|      * 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<String>) | ||||
| } | ||||
|   | ||||
| @@ -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<jclass>(env->NewGlobalRef(string_class)); | ||||
|     env->DeleteLocalRef(string_class); | ||||
|  | ||||
|     const jclass pair_class = env->FindClass("kotlin/Pair"); | ||||
|     s_pair_class = reinterpret_cast<jclass>(env->NewGlobalRef(pair_class)); | ||||
|     s_pair_constructor = | ||||
|         env->GetMethodID(pair_class, "<init>", "(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); | ||||
|   | ||||
| @@ -20,4 +20,10 @@ jmethodID GetDiskCacheLoadProgress(); | ||||
| jmethodID GetOnEmulationStarted(); | ||||
| jmethodID GetOnEmulationStopped(); | ||||
|  | ||||
| jclass GetStringClass(); | ||||
| jclass GetPairClass(); | ||||
| jmethodID GetPairConstructor(); | ||||
| jfieldID GetPairFirstField(); | ||||
| jfieldID GetPairSecondField(); | ||||
|  | ||||
| } // namespace IDCache | ||||
|   | ||||
| @@ -14,6 +14,7 @@ | ||||
| #include <android/api-level.h> | ||||
| #include <android/native_window_jni.h> | ||||
| #include <common/fs/fs.h> | ||||
| #include <core/file_sys/patch_manager.h> | ||||
| #include <core/file_sys/savedata_factory.h> | ||||
| #include <core/loader/nro.h> | ||||
| #include <jni.h> | ||||
| @@ -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<jint>(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<FileSys::NSP> nsp = std::make_shared<FileSys::NSP>( | ||||
|         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<std::size_t>(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" | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -283,4 +283,30 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject | ||||
|         AndroidSettings::GameDir{uriString, static_cast<bool>(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<std::string> disabled_addons; | ||||
|     const int size = env->GetArrayLength(jdisabledAddons); | ||||
|     for (int i = 0; i < size; ++i) { | ||||
|         auto jaddon = static_cast<jstring>(env->GetObjectArrayElement(jdisabledAddons, i)); | ||||
|         disabled_addons.push_back(GetJString(env, jaddon)); | ||||
|     } | ||||
|     Settings::values.disabled_addons[program_id] = disabled_addons; | ||||
| } | ||||
|  | ||||
| } // extern "C" | ||||
|   | ||||
| @@ -0,0 +1,99 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout | ||||
|     xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="?attr/colorSurface"> | ||||
|  | ||||
|     <androidx.core.widget.NestedScrollView | ||||
|         android:id="@+id/list_all" | ||||
|         android:layout_width="0dp" | ||||
|         android:layout_height="match_parent" | ||||
|         android:clipToPadding="false" | ||||
|         android:fadeScrollbars="false" | ||||
|         android:scrollbars="vertical" | ||||
|         app:layout_constraintEnd_toEndOf="parent" | ||||
|         app:layout_constraintStart_toEndOf="@+id/icon_layout" | ||||
|         app:layout_constraintTop_toTopOf="parent"> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:id="@+id/layout_all" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:gravity="center_horizontal" | ||||
|             android:orientation="horizontal"> | ||||
|  | ||||
|             <androidx.recyclerview.widget.RecyclerView | ||||
|                 android:id="@+id/list_properties" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 tools:listitem="@layout/card_simple_outlined" /> | ||||
|  | ||||
|         </LinearLayout> | ||||
|  | ||||
|     </androidx.core.widget.NestedScrollView> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:id="@+id/icon_layout" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:orientation="vertical" | ||||
|         app:layout_constraintStart_toStartOf="parent" | ||||
|         app:layout_constraintTop_toTopOf="parent"> | ||||
|  | ||||
|         <Button | ||||
|             android:id="@+id/button_back" | ||||
|             style="?attr/materialIconButtonStyle" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_gravity="start" | ||||
|             android:layout_margin="8dp" | ||||
|             app:icon="@drawable/ic_back" | ||||
|             app:iconSize="24dp" | ||||
|             app:iconTint="?attr/colorOnSurface" /> | ||||
|  | ||||
|         <com.google.android.material.card.MaterialCardView | ||||
|             style="?attr/materialCardViewElevatedStyle" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginHorizontal="16dp" | ||||
|             android:layout_marginTop="8dp" | ||||
|             app:cardCornerRadius="4dp" | ||||
|             app:cardElevation="4dp"> | ||||
|  | ||||
|             <ImageView | ||||
|                 android:id="@+id/image_game_screen" | ||||
|                 android:layout_width="175dp" | ||||
|                 android:layout_height="175dp" | ||||
|                 tools:src="@drawable/default_icon" /> | ||||
|  | ||||
|         </com.google.android.material.card.MaterialCardView> | ||||
|  | ||||
|         <com.google.android.material.textview.MaterialTextView | ||||
|             android:id="@+id/title" | ||||
|             style="@style/TextAppearance.Material3.TitleMedium" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginHorizontal="16dp" | ||||
|             android:layout_marginTop="12dp" | ||||
|             android:ellipsize="none" | ||||
|             android:marqueeRepeatLimit="marquee_forever" | ||||
|             android:requiresFadingEdge="horizontal" | ||||
|             android:singleLine="true" | ||||
|             android:textAlignment="center" | ||||
|             tools:text="deko_basic" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
|  | ||||
|     <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton | ||||
|         android:id="@+id/button_start" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:text="@string/start" | ||||
|         app:icon="@drawable/ic_play" | ||||
|         app:layout_constraintBottom_toBottomOf="parent" | ||||
|         app:layout_constraintEnd_toEndOf="parent" /> | ||||
|  | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| @@ -11,7 +11,8 @@ | ||||
|     <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_margin="16dp" | ||||
|         android:paddingVertical="16dp" | ||||
|         android:paddingHorizontal="24dp" | ||||
|         android:orientation="horizontal" | ||||
|         android:layout_gravity="center"> | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,8 @@ | ||||
|         android:layout_height="wrap_content" | ||||
|         android:orientation="horizontal" | ||||
|         android:layout_gravity="center" | ||||
|         android:padding="24dp"> | ||||
|         android:paddingVertical="16dp" | ||||
|         android:paddingHorizontal="24dp"> | ||||
| 
 | ||||
|         <ImageView | ||||
|             android:id="@+id/icon" | ||||
| @@ -50,6 +51,23 @@ | ||||
|                 android:textAlignment="viewStart" | ||||
|                 tools:text="@string/applets_description" /> | ||||
| 
 | ||||
|             <com.google.android.material.textview.MaterialTextView | ||||
|                 style="@style/TextAppearance.Material3.LabelMedium" | ||||
|                 android:id="@+id/details" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:textAlignment="viewStart" | ||||
|                 android:textSize="14sp" | ||||
|                 android:textStyle="bold" | ||||
|                 android:singleLine="true" | ||||
|                 android:marqueeRepeatLimit="marquee_forever" | ||||
|                 android:ellipsize="none" | ||||
|                 android:requiresFadingEdge="horizontal" | ||||
|                 android:layout_marginTop="6dp" | ||||
|                 android:visibility="gone" | ||||
|                 tools:visibility="visible" | ||||
|                 tools:text="/tree/primary:Games" /> | ||||
| 
 | ||||
|         </LinearLayout> | ||||
| 
 | ||||
|     </LinearLayout> | ||||
							
								
								
									
										47
									
								
								src/android/app/src/main/res/layout/fragment_addons.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/android/app/src/main/res/layout/fragment_addons.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:id="@+id/coordinator_about" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="?attr/colorSurface"> | ||||
|  | ||||
|     <com.google.android.material.appbar.AppBarLayout | ||||
|         android:id="@+id/appbar_addons" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:fitsSystemWindows="true" | ||||
|         app:layout_constraintEnd_toEndOf="parent" | ||||
|         app:layout_constraintStart_toStartOf="parent" | ||||
|         app:layout_constraintTop_toTopOf="parent"> | ||||
|  | ||||
|         <com.google.android.material.appbar.MaterialToolbar | ||||
|             android:id="@+id/toolbar_addons" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="?attr/actionBarSize" | ||||
|             app:navigationIcon="@drawable/ic_back" /> | ||||
|  | ||||
|     </com.google.android.material.appbar.AppBarLayout> | ||||
|  | ||||
|     <androidx.recyclerview.widget.RecyclerView | ||||
|         android:id="@+id/list_addons" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="0dp" | ||||
|         android:clipToPadding="false" | ||||
|         app:layout_behavior="@string/appbar_scrolling_view_behavior" | ||||
|         app:layout_constraintBottom_toBottomOf="parent" | ||||
|         app:layout_constraintEnd_toEndOf="parent" | ||||
|         app:layout_constraintStart_toStartOf="parent" | ||||
|         app:layout_constraintTop_toBottomOf="@+id/appbar_addons" /> | ||||
|  | ||||
|     <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton | ||||
|         android:id="@+id/button_install" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="bottom|end" | ||||
|         android:text="@string/install" | ||||
|         app:icon="@drawable/ic_add" | ||||
|         app:layout_constraintBottom_toBottomOf="parent" | ||||
|         app:layout_constraintEnd_toEndOf="parent" /> | ||||
|  | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
							
								
								
									
										125
									
								
								src/android/app/src/main/res/layout/fragment_game_info.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/android/app/src/main/res/layout/fragment_game_info.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/coordinator_about" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="?attr/colorSurface"> | ||||
|  | ||||
|     <com.google.android.material.appbar.AppBarLayout | ||||
|         android:id="@+id/appbar_info" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:fitsSystemWindows="true"> | ||||
|  | ||||
|         <com.google.android.material.appbar.MaterialToolbar | ||||
|             android:id="@+id/toolbar_info" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="?attr/actionBarSize" | ||||
|             app:navigationIcon="@drawable/ic_back" /> | ||||
|  | ||||
|     </com.google.android.material.appbar.AppBarLayout> | ||||
|  | ||||
|     <androidx.core.widget.NestedScrollView | ||||
|         android:id="@+id/scroll_info" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         app:layout_behavior="@string/appbar_scrolling_view_behavior"> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:id="@+id/content_info" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:orientation="vertical" | ||||
|             android:paddingHorizontal="16dp"> | ||||
|  | ||||
|             <com.google.android.material.textfield.TextInputLayout | ||||
|                 android:id="@+id/path" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:paddingTop="16dp"> | ||||
|  | ||||
|                 <com.google.android.material.textfield.TextInputEditText | ||||
|                     android:id="@+id/path_field" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:editable="false" | ||||
|                     android:importantForAutofill="no" | ||||
|                     android:inputType="none" | ||||
|                     android:minHeight="48dp" | ||||
|                     android:textAlignment="viewStart" | ||||
|                     tools:text="1.0.0" /> | ||||
|  | ||||
|             </com.google.android.material.textfield.TextInputLayout> | ||||
|  | ||||
|             <com.google.android.material.textfield.TextInputLayout | ||||
|                 android:id="@+id/program_id" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:paddingTop="16dp"> | ||||
|  | ||||
|                 <com.google.android.material.textfield.TextInputEditText | ||||
|                     android:id="@+id/program_id_field" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:editable="false" | ||||
|                     android:importantForAutofill="no" | ||||
|                     android:inputType="none" | ||||
|                     android:minHeight="48dp" | ||||
|                     android:textAlignment="viewStart" | ||||
|                     tools:text="1.0.0" /> | ||||
|  | ||||
|             </com.google.android.material.textfield.TextInputLayout> | ||||
|  | ||||
|             <com.google.android.material.textfield.TextInputLayout | ||||
|                 android:id="@+id/developer" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:paddingTop="16dp"> | ||||
|  | ||||
|                 <com.google.android.material.textfield.TextInputEditText | ||||
|                     android:id="@+id/developer_field" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:editable="false" | ||||
|                     android:importantForAutofill="no" | ||||
|                     android:inputType="none" | ||||
|                     android:minHeight="48dp" | ||||
|                     android:textAlignment="viewStart" | ||||
|                     tools:text="1.0.0" /> | ||||
|  | ||||
|             </com.google.android.material.textfield.TextInputLayout> | ||||
|  | ||||
|             <com.google.android.material.textfield.TextInputLayout | ||||
|                 android:id="@+id/version" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:paddingTop="16dp"> | ||||
|  | ||||
|                 <com.google.android.material.textfield.TextInputEditText | ||||
|                     android:id="@+id/version_field" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:editable="false" | ||||
|                     android:importantForAutofill="no" | ||||
|                     android:inputType="none" | ||||
|                     android:minHeight="48dp" | ||||
|                     android:textAlignment="viewStart" | ||||
|                     tools:text="1.0.0" /> | ||||
|  | ||||
|             </com.google.android.material.textfield.TextInputLayout> | ||||
|  | ||||
|             <com.google.android.material.button.MaterialButton | ||||
|                 android:id="@+id/button_copy" | ||||
|                 style="@style/Widget.Material3.Button" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_marginTop="16dp" | ||||
|                 android:text="@string/copy_details" /> | ||||
|  | ||||
|         </LinearLayout> | ||||
|  | ||||
|     </androidx.core.widget.NestedScrollView> | ||||
|  | ||||
| </androidx.coordinatorlayout.widget.CoordinatorLayout> | ||||
| @@ -0,0 +1,86 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout | ||||
|     xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="?attr/colorSurface"> | ||||
|  | ||||
|     <androidx.core.widget.NestedScrollView | ||||
|         android:id="@+id/list_all" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" | ||||
|         android:scrollbars="vertical" | ||||
|         android:fadeScrollbars="false" | ||||
|         android:clipToPadding="false"> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:id="@+id/layout_all" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:orientation="vertical" | ||||
|             android:gravity="center_horizontal"> | ||||
|  | ||||
|             <Button | ||||
|                 android:id="@+id/button_back" | ||||
|                 style="?attr/materialIconButtonStyle" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_margin="8dp" | ||||
|                 android:layout_gravity="start" | ||||
|                 app:icon="@drawable/ic_back" | ||||
|                 app:iconSize="24dp" | ||||
|                 app:iconTint="?attr/colorOnSurface" /> | ||||
|  | ||||
|             <com.google.android.material.card.MaterialCardView | ||||
|                 style="?attr/materialCardViewElevatedStyle" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_marginTop="8dp" | ||||
|                 app:cardCornerRadius="4dp" | ||||
|                 app:cardElevation="4dp"> | ||||
|  | ||||
|                 <ImageView | ||||
|                     android:id="@+id/image_game_screen" | ||||
|                     android:layout_width="175dp" | ||||
|                     android:layout_height="175dp" | ||||
|                     tools:src="@drawable/default_icon"/> | ||||
|  | ||||
|             </com.google.android.material.card.MaterialCardView> | ||||
|  | ||||
|             <com.google.android.material.textview.MaterialTextView | ||||
|                 android:id="@+id/title" | ||||
|                 style="@style/TextAppearance.Material3.TitleMedium" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_marginTop="12dp" | ||||
|                 android:layout_marginBottom="12dp" | ||||
|                 android:layout_marginHorizontal="16dp" | ||||
|                 android:ellipsize="none" | ||||
|                 android:marqueeRepeatLimit="marquee_forever" | ||||
|                 android:requiresFadingEdge="horizontal" | ||||
|                 android:singleLine="true" | ||||
|                 android:textAlignment="center" | ||||
|                 tools:text="deko_basic" /> | ||||
|  | ||||
|             <androidx.recyclerview.widget.RecyclerView | ||||
|                 android:id="@+id/list_properties" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 tools:listitem="@layout/card_simple_outlined" /> | ||||
|  | ||||
|         </LinearLayout> | ||||
|  | ||||
|     </androidx.core.widget.NestedScrollView> | ||||
|  | ||||
|     <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton | ||||
|         android:id="@+id/button_start" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:text="@string/start" | ||||
|         app:icon="@drawable/ic_play" | ||||
|         app:layout_constraintBottom_toBottomOf="parent" | ||||
|         app:layout_constraintEnd_toEndOf="parent" /> | ||||
|  | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
							
								
								
									
										57
									
								
								src/android/app/src/main/res/layout/list_item_addon.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/android/app/src/main/res/layout/list_item_addon.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/addon_container" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     android:background="?attr/selectableItemBackground" | ||||
|     android:focusable="true" | ||||
|     android:paddingHorizontal="20dp" | ||||
|     android:paddingVertical="16dp"> | ||||
|  | ||||
|     <LinearLayout | ||||
|         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" | ||||
|         app:layout_constraintStart_toStartOf="parent" | ||||
|         app:layout_constraintTop_toTopOf="@+id/addon_switch"> | ||||
|  | ||||
|         <com.google.android.material.textview.MaterialTextView | ||||
|             android:id="@+id/title" | ||||
|             style="@style/TextAppearance.Material3.HeadlineMedium" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:textAlignment="viewStart" | ||||
|             android:textSize="17sp" | ||||
|             app:lineHeight="28dp" | ||||
|             tools:text="1440p Resolution" /> | ||||
|  | ||||
|         <com.google.android.material.textview.MaterialTextView | ||||
|             android:id="@+id/version" | ||||
|             style="@style/TextAppearance.Material3.BodySmall" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginTop="@dimen/spacing_small" | ||||
|             android:textAlignment="viewStart" | ||||
|             tools:text="1.0.0" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
|  | ||||
|     <com.google.android.material.materialswitch.MaterialSwitch | ||||
|         android:id="@+id/addon_switch" | ||||
|         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" | ||||
|         app:layout_constraintEnd_toEndOf="parent" | ||||
|         app:layout_constraintStart_toEndOf="@id/text_container" | ||||
|         app:layout_constraintTop_toTopOf="parent" /> | ||||
|  | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| @@ -124,5 +124,38 @@ | ||||
|         android:id="@+id/gameFoldersFragment" | ||||
|         android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment" | ||||
|         android:label="GameFoldersFragment" /> | ||||
|     <fragment | ||||
|         android:id="@+id/perGamePropertiesFragment" | ||||
|         android:name="org.yuzu.yuzu_emu.fragments.GamePropertiesFragment" | ||||
|         android:label="PerGamePropertiesFragment" > | ||||
|         <argument | ||||
|             android:name="game" | ||||
|             app:argType="org.yuzu.yuzu_emu.model.Game" /> | ||||
|         <action | ||||
|             android:id="@+id/action_perGamePropertiesFragment_to_gameInfoFragment" | ||||
|             app:destination="@id/gameInfoFragment" /> | ||||
|         <action | ||||
|             android:id="@+id/action_perGamePropertiesFragment_to_addonsFragment" | ||||
|             app:destination="@id/addonsFragment" /> | ||||
|     </fragment> | ||||
|     <action | ||||
|         android:id="@+id/action_global_perGamePropertiesFragment" | ||||
|         app:destination="@id/perGamePropertiesFragment" /> | ||||
|     <fragment | ||||
|         android:id="@+id/gameInfoFragment" | ||||
|         android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment" | ||||
|         android:label="GameInfoFragment" > | ||||
|         <argument | ||||
|             android:name="game" | ||||
|             app:argType="org.yuzu.yuzu_emu.model.Game" /> | ||||
|     </fragment> | ||||
|     <fragment | ||||
|         android:id="@+id/addonsFragment" | ||||
|         android:name="org.yuzu.yuzu_emu.fragments.AddonsFragment" | ||||
|         android:label="AddonsFragment" > | ||||
|         <argument | ||||
|             android:name="game" | ||||
|             app:argType="org.yuzu.yuzu_emu.model.Game" /> | ||||
|     </fragment> | ||||
|  | ||||
| </navigation> | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
|     <dimen name="menu_width">256dp</dimen> | ||||
|     <dimen name="card_width">165dp</dimen> | ||||
|     <dimen name="icon_inset">24dp</dimen> | ||||
|     <dimen name="spacing_bottom_list_fab">76dp</dimen> | ||||
|     <dimen name="spacing_bottom_list_fab">96dp</dimen> | ||||
|     <dimen name="spacing_fab">24dp</dimen> | ||||
|  | ||||
|     <dimen name="dialog_margin">20dp</dimen> | ||||
|   | ||||
| @@ -91,7 +91,10 @@ | ||||
|     <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string> | ||||
|     <string name="manage_save_data">Manage save data</string> | ||||
|     <string name="manage_save_data_description">Save data found. Please select an option below.</string> | ||||
|     <string name="import_save_warning">Import save data</string> | ||||
|     <string name="import_save_warning_description">This will overwrite all existing save data with the provided file. Are you sure that you want to continue?</string> | ||||
|     <string name="import_export_saves_description">Import or export save files</string> | ||||
|     <string name="save_files_importing">Importing save files…</string> | ||||
|     <string name="save_files_exporting">Exporting save files…</string> | ||||
|     <string name="save_file_imported_success">Imported successfully</string> | ||||
|     <string name="save_file_invalid_zip_structure">Invalid save directory structure</string> | ||||
| @@ -266,6 +269,11 @@ | ||||
|     <string name="delete">Delete</string> | ||||
|     <string name="edit">Edit</string> | ||||
|     <string name="export_success">Exported successfully</string> | ||||
|     <string name="start">Start</string> | ||||
|     <string name="clear">Clear</string> | ||||
|     <string name="global">Global</string> | ||||
|     <string name="custom">Custom</string> | ||||
|     <string name="notice">Notice</string> | ||||
|  | ||||
|     <!-- GPU driver installation --> | ||||
|     <string name="select_gpu_driver">Select GPU driver</string> | ||||
| @@ -291,6 +299,43 @@ | ||||
|     <string name="preferences_debug">Debug</string> | ||||
|     <string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string> | ||||
|  | ||||
|     <!-- Game properties --> | ||||
|     <string name="info">Info</string> | ||||
|     <string name="info_description">Program ID, developer, version</string> | ||||
|     <string name="per_game_settings">Per-game settings</string> | ||||
|     <string name="per_game_settings_description">Edit settings specific to this game</string> | ||||
|     <string name="launch_options">Launch config</string> | ||||
|     <string name="path">Path</string> | ||||
|     <string name="program_id">Program ID</string> | ||||
|     <string name="developer">Developer</string> | ||||
|     <string name="version">Version</string> | ||||
|     <string name="copy_details">Copy details</string> | ||||
|     <string name="add_ons">Add-ons</string> | ||||
|     <string name="add_ons_description">Toggle mods, updates and DLC</string> | ||||
|     <string name="clear_shader_cache">Clear shader cache</string> | ||||
|     <string name="clear_shader_cache_description">Removes all shaders built while playing this game</string> | ||||
|     <string name="cleared_shaders_successfully">Cleared shaders successfully</string> | ||||
|     <string name="addons_game">Addons: %1$s</string> | ||||
|     <string name="save_data">Save data</string> | ||||
|     <string name="save_data_description">Manage save data specific to this game</string> | ||||
|     <string name="delete_save_data">Delete save data</string> | ||||
|     <string name="delete_save_data_description">Removes all save data specific to this game</string> | ||||
|     <string name="delete_save_data_warning_description">This irrecoverably removes all of this game\'s save data. Are you sure you want to continue?</string> | ||||
|     <string name="save_data_deleted_successfully">Save data deleted successfully</string> | ||||
|     <string name="select_content_type">Content type</string> | ||||
|     <string name="updates_and_dlc">Updates and DLC</string> | ||||
|     <string name="mods_and_cheats">Mods and cheats</string> | ||||
|     <string name="addon_notice">Important addon notice</string> | ||||
|     <!-- "cheats/" "romfs/" and "exefs/ should not be translated --> | ||||
|     <string name="addon_notice_description">In order to install mods and cheats, you must select a folder that contains a cheats/, romfs/, or exefs/ directory. We can\'t verify if these will be compatible with your game so be careful!</string> | ||||
|     <string name="invalid_directory">Invalid directory</string> | ||||
|     <!-- "cheats/" "romfs/" and "exefs/ should not be translated --> | ||||
|     <string name="invalid_directory_description">Please make sure that the directory you selected contains a cheats/, romfs/, or exefs/ folder and try again.</string> | ||||
|     <string name="addon_installed_successfully">Addon installed successfully</string> | ||||
|     <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> | ||||
|  | ||||
|     <!-- ROM loading errors --> | ||||
|     <string name="loader_error_encrypted">Your ROM is encrypted</string> | ||||
|     <string name="loader_error_encrypted_roms_description"><![CDATA[Please follow the guides to redump your <a href="https://yuzu-emu.org/help/quickstart/#dumping-physical-titles-game-cards">game cartidges</a> or <a href="https://yuzu-emu.org/help/quickstart/#dumping-digital-titles-eshop">installed titles</a>.]]></string> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 t895
					t895