summaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorGravatar t8952023-12-10 20:27:50 -0500
committerGravatar t8952023-12-12 17:25:36 -0500
commite975f3cde9d4dcb1d9e2bbce116f9a9ba99bf03f (patch)
tree7195fba36da6368913d75d633649d74915383cd8 /src/android
parentfrontend_common: Fix settings reload bug (diff)
downloadyuzu-e975f3cde9d4dcb1d9e2bbce116f9a9ba99bf03f.tar.gz
yuzu-e975f3cde9d4dcb1d9e2bbce116f9a9ba99bf03f.tar.xz
yuzu-e975f3cde9d4dcb1d9e2bbce116f9a9ba99bf03f.zip
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
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt35
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt52
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt6
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt16
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt133
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt214
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt68
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt148
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt418
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt37
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt10
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt83
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt42
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt34
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt15
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt327
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt32
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt19
-rw-r--r--src/android/app/src/main/jni/id_cache.cpp40
-rw-r--r--src/android/app/src/main/jni/id_cache.h6
-rw-r--r--src/android/app/src/main/jni/native.cpp108
-rw-r--r--src/android/app/src/main/jni/native.h2
-rw-r--r--src/android/app/src/main/jni/native_config.cpp26
-rw-r--r--src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml99
-rw-r--r--src/android/app/src/main/res/layout/card_installable.xml3
-rw-r--r--src/android/app/src/main/res/layout/card_simple_outlined.xml (renamed from src/android/app/src/main/res/layout/card_applet_option.xml)20
-rw-r--r--src/android/app/src/main/res/layout/fragment_addons.xml47
-rw-r--r--src/android/app/src/main/res/layout/fragment_game_info.xml125
-rw-r--r--src/android/app/src/main/res/layout/fragment_game_properties.xml86
-rw-r--r--src/android/app/src/main/res/layout/list_item_addon.xml57
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml33
-rw-r--r--src/android/app/src/main/res/values/dimens.xml2
-rw-r--r--src/android/app/src/main/res/values/strings.xml45
40 files changed, 2227 insertions, 253 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index e0f01127c..95b98798d 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -230,8 +230,6 @@ object NativeLibrary {
230 */ 230 */
231 external fun onTouchReleased(finger_id: Int) 231 external fun onTouchReleased(finger_id: Int)
232 232
233 external fun initGameIni(gameID: String?)
234
235 external fun setAppDirectory(directory: String) 233 external fun setAppDirectory(directory: String)
236 234
237 /** 235 /**
@@ -241,6 +239,8 @@ object NativeLibrary {
241 */ 239 */
242 external fun installFileToNand(filename: String, extension: String): Int 240 external fun installFileToNand(filename: String, extension: String): Int
243 241
242 external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean
243
244 external fun initializeGpuDriver( 244 external fun initializeGpuDriver(
245 hookLibDir: String?, 245 hookLibDir: String?,
246 customDriverDir: String?, 246 customDriverDir: String?,
@@ -252,18 +252,11 @@ object NativeLibrary {
252 252
253 external fun initializeSystem(reload: Boolean) 253 external fun initializeSystem(reload: Boolean)
254 254
255 external fun defaultCPUCore(): Int
256
257 /** 255 /**
258 * Begins emulation. 256 * Begins emulation.
259 */ 257 */
260 external fun run(path: String?) 258 external fun run(path: String?)
261 259
262 /**
263 * Begins emulation from the specified savestate.
264 */
265 external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
266
267 // Surface Handling 260 // Surface Handling
268 external fun surfaceChanged(surf: Surface?) 261 external fun surfaceChanged(surf: Surface?)
269 262
@@ -304,10 +297,9 @@ object NativeLibrary {
304 */ 297 */
305 external fun getCpuBackend(): String 298 external fun getCpuBackend(): String
306 299
307 /** 300 external fun applySettings()
308 * Notifies the core emulation that the orientation has changed. 301
309 */ 302 external fun logSettings()
310 external fun notifyOrientationChange(layout_option: Int, rotation: Int)
311 303
312 enum class CoreError { 304 enum class CoreError {
313 ErrorSystemFiles, 305 ErrorSystemFiles,
@@ -539,6 +531,23 @@ object NativeLibrary {
539 external fun isFirmwareAvailable(): Boolean 531 external fun isFirmwareAvailable(): Boolean
540 532
541 /** 533 /**
534 * Checks the PatchManager for any addons that are available
535 *
536 * @param path Path to game file. Can be a [Uri].
537 * @param programId String representation of a game's program ID
538 * @return Array of pairs where the first value is the name of an addon and the second is the version
539 */
540 external fun getAddonsForFile(path: String, programId: String): Array<Pair<String, String>>?
541
542 /**
543 * Gets the save location for a specific game
544 *
545 * @param programId String representation of a game's program ID
546 * @return Save data path that may not exist yet
547 */
548 external fun getSavePath(programId: String): String
549
550 /**
542 * Button type for use in onTouchEvent 551 * Button type for use in onTouchEvent
543 */ 552 */
544 object ButtonType { 553 object ButtonType {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
new file mode 100644
index 000000000..15c7ca3c9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
@@ -0,0 +1,52 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.adapters
5
6import android.view.LayoutInflater
7import android.view.ViewGroup
8import androidx.recyclerview.widget.AsyncDifferConfig
9import androidx.recyclerview.widget.DiffUtil
10import androidx.recyclerview.widget.ListAdapter
11import androidx.recyclerview.widget.RecyclerView
12import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
13import org.yuzu.yuzu_emu.model.Addon
14
15class AddonAdapter : ListAdapter<Addon, AddonAdapter.AddonViewHolder>(
16 AsyncDifferConfig.Builder(DiffCallback()).build()
17) {
18 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
19 ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
20 .also { return AddonViewHolder(it) }
21 }
22
23 override fun getItemCount(): Int = currentList.size
24
25 override fun onBindViewHolder(holder: AddonViewHolder, position: Int) =
26 holder.bind(currentList[position])
27
28 inner class AddonViewHolder(val binding: ListItemAddonBinding) :
29 RecyclerView.ViewHolder(binding.root) {
30 fun bind(addon: Addon) {
31 binding.root.setOnClickListener {
32 binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked
33 }
34 binding.title.text = addon.title
35 binding.version.text = addon.version
36 binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
37 addon.enabled = checked
38 }
39 binding.addonSwitch.isChecked = addon.enabled
40 }
41 }
42
43 private class DiffCallback : DiffUtil.ItemCallback<Addon>() {
44 override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean {
45 return oldItem == newItem
46 }
47
48 override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean {
49 return oldItem == newItem
50 }
51 }
52}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
index a21a705c1..4a05c5be9 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
@@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections
15import org.yuzu.yuzu_emu.NativeLibrary 15import org.yuzu.yuzu_emu.NativeLibrary
16import org.yuzu.yuzu_emu.R 16import org.yuzu.yuzu_emu.R
17import org.yuzu.yuzu_emu.YuzuApplication 17import org.yuzu.yuzu_emu.YuzuApplication
18import org.yuzu.yuzu_emu.databinding.CardAppletOptionBinding 18import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
19import org.yuzu.yuzu_emu.model.Applet 19import org.yuzu.yuzu_emu.model.Applet
20import org.yuzu.yuzu_emu.model.AppletInfo 20import org.yuzu.yuzu_emu.model.AppletInfo
21import org.yuzu.yuzu_emu.model.Game 21import org.yuzu.yuzu_emu.model.Game
@@ -28,7 +28,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
28 parent: ViewGroup, 28 parent: ViewGroup,
29 viewType: Int 29 viewType: Int
30 ): AppletAdapter.AppletViewHolder { 30 ): AppletAdapter.AppletViewHolder {
31 CardAppletOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) 31 CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false)
32 .apply { root.setOnClickListener(this@AppletAdapter) } 32 .apply { root.setOnClickListener(this@AppletAdapter) }
33 .also { return AppletViewHolder(it) } 33 .also { return AppletViewHolder(it) }
34 } 34 }
@@ -65,7 +65,7 @@ class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
65 view.findNavController().navigate(action) 65 view.findNavController().navigate(action)
66 } 66 }
67 67
68 inner class AppletViewHolder(val binding: CardAppletOptionBinding) : 68 inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) :
69 RecyclerView.ViewHolder(binding.root) { 69 RecyclerView.ViewHolder(binding.root) {
70 lateinit var applet: Applet 70 lateinit var applet: Applet
71 71
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
index 2ef638559..928bfe5a7 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
@@ -44,19 +44,20 @@ import org.yuzu.yuzu_emu.utils.GameIconUtils
44 44
45class GameAdapter(private val activity: AppCompatActivity) : 45class GameAdapter(private val activity: AppCompatActivity) :
46 ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()), 46 ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
47 View.OnClickListener { 47 View.OnClickListener,
48 View.OnLongClickListener {
48 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { 49 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
49 // Create a new view. 50 // Create a new view.
50 val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) 51 val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
51 binding.cardGame.setOnClickListener(this) 52 binding.cardGame.setOnClickListener(this)
53 binding.cardGame.setOnLongClickListener(this)
52 54
53 // Use that view to create a ViewHolder. 55 // Use that view to create a ViewHolder.
54 return GameViewHolder(binding) 56 return GameViewHolder(binding)
55 } 57 }
56 58
57 override fun onBindViewHolder(holder: GameViewHolder, position: Int) { 59 override fun onBindViewHolder(holder: GameViewHolder, position: Int) =
58 holder.bind(currentList[position]) 60 holder.bind(currentList[position])
59 }
60 61
61 override fun getItemCount(): Int = currentList.size 62 override fun getItemCount(): Int = currentList.size
62 63
@@ -125,8 +126,15 @@ class GameAdapter(private val activity: AppCompatActivity) :
125 } 126 }
126 } 127 }
127 128
128 val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game) 129 val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game, true)
130 view.findNavController().navigate(action)
131 }
132
133 override fun onLongClick(view: View): Boolean {
134 val holder = view.tag as GameViewHolder
135 val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(holder.game)
129 view.findNavController().navigate(action) 136 view.findNavController().navigate(action)
137 return true
130 } 138 }
131 139
132 inner class GameViewHolder(val binding: CardGameBinding) : 140 inner class GameViewHolder(val binding: CardGameBinding) :
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
new file mode 100644
index 000000000..ff6270fa8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
@@ -0,0 +1,133 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.adapters
5
6import android.text.TextUtils
7import android.view.LayoutInflater
8import android.view.View
9import android.view.ViewGroup
10import androidx.core.content.res.ResourcesCompat
11import androidx.lifecycle.Lifecycle
12import androidx.lifecycle.LifecycleOwner
13import androidx.lifecycle.lifecycleScope
14import androidx.lifecycle.repeatOnLifecycle
15import androidx.recyclerview.widget.RecyclerView
16import kotlinx.coroutines.launch
17import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
18import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
19import org.yuzu.yuzu_emu.model.GameProperty
20import org.yuzu.yuzu_emu.model.InstallableProperty
21import org.yuzu.yuzu_emu.model.SubmenuProperty
22
23class GamePropertiesAdapter(
24 private val viewLifecycle: LifecycleOwner,
25 private var properties: List<GameProperty>
26) :
27 RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() {
28 override fun onCreateViewHolder(
29 parent: ViewGroup,
30 viewType: Int
31 ): GamePropertyViewHolder {
32 val inflater = LayoutInflater.from(parent.context)
33 return when (viewType) {
34 PropertyType.Submenu.ordinal -> {
35 SubmenuPropertyViewHolder(
36 CardSimpleOutlinedBinding.inflate(
37 inflater,
38 parent,
39 false
40 )
41 )
42 }
43
44 else -> InstallablePropertyViewHolder(
45 CardInstallableBinding.inflate(
46 inflater,
47 parent,
48 false
49 )
50 )
51 }
52 }
53
54 override fun getItemCount(): Int = properties.size
55
56 override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) =
57 holder.bind(properties[position])
58
59 override fun getItemViewType(position: Int): Int {
60 return when (properties[position]) {
61 is SubmenuProperty -> PropertyType.Submenu.ordinal
62 else -> PropertyType.Installable.ordinal
63 }
64 }
65
66 sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
67 abstract fun bind(property: GameProperty)
68 }
69
70 inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) :
71 GamePropertyViewHolder(binding.root) {
72 override fun bind(property: GameProperty) {
73 val submenuProperty = property as SubmenuProperty
74
75 binding.root.setOnClickListener {
76 submenuProperty.action.invoke()
77 }
78
79 binding.title.setText(submenuProperty.titleId)
80 binding.description.setText(submenuProperty.descriptionId)
81 binding.icon.setImageDrawable(
82 ResourcesCompat.getDrawable(
83 binding.icon.context.resources,
84 submenuProperty.iconId,
85 binding.icon.context.theme
86 )
87 )
88
89 binding.details.postDelayed({
90 binding.details.isSelected = true
91 binding.details.ellipsize = TextUtils.TruncateAt.MARQUEE
92 }, 3000)
93
94 if (submenuProperty.details != null) {
95 binding.details.visibility = View.VISIBLE
96 binding.details.text = submenuProperty.details.invoke()
97 } else if (submenuProperty.detailsFlow != null) {
98 binding.details.visibility = View.VISIBLE
99 viewLifecycle.lifecycleScope.launch {
100 viewLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
101 submenuProperty.detailsFlow.collect { binding.details.text = it }
102 }
103 }
104 } else {
105 binding.details.visibility = View.GONE
106 }
107 }
108 }
109
110 inner class InstallablePropertyViewHolder(val binding: CardInstallableBinding) :
111 GamePropertyViewHolder(binding.root) {
112 override fun bind(property: GameProperty) {
113 val installableProperty = property as InstallableProperty
114
115 binding.title.setText(installableProperty.titleId)
116 binding.description.setText(installableProperty.descriptionId)
117
118 if (installableProperty.install != null) {
119 binding.buttonInstall.visibility = View.VISIBLE
120 binding.buttonInstall.setOnClickListener { installableProperty.install.invoke() }
121 }
122 if (installableProperty.export != null) {
123 binding.buttonExport.visibility = View.VISIBLE
124 binding.buttonExport.setOnClickListener { installableProperty.export.invoke() }
125 }
126 }
127 }
128
129 enum class PropertyType {
130 Submenu,
131 Installable
132 }
133}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
new file mode 100644
index 000000000..0dce8ad8d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
@@ -0,0 +1,214 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.annotation.SuppressLint
7import android.content.Intent
8import android.os.Bundle
9import android.view.LayoutInflater
10import android.view.View
11import android.view.ViewGroup
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.core.view.ViewCompat
14import androidx.core.view.WindowInsetsCompat
15import androidx.core.view.updatePadding
16import androidx.documentfile.provider.DocumentFile
17import androidx.fragment.app.Fragment
18import androidx.fragment.app.activityViewModels
19import androidx.lifecycle.Lifecycle
20import androidx.lifecycle.lifecycleScope
21import androidx.lifecycle.repeatOnLifecycle
22import androidx.navigation.findNavController
23import androidx.navigation.fragment.navArgs
24import androidx.recyclerview.widget.LinearLayoutManager
25import com.google.android.material.transition.MaterialSharedAxis
26import kotlinx.coroutines.launch
27import org.yuzu.yuzu_emu.R
28import org.yuzu.yuzu_emu.adapters.AddonAdapter
29import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding
30import org.yuzu.yuzu_emu.model.AddonViewModel
31import org.yuzu.yuzu_emu.model.HomeViewModel
32import org.yuzu.yuzu_emu.utils.AddonUtil
33import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo
34import java.io.File
35
36class AddonsFragment : Fragment() {
37 private var _binding: FragmentAddonsBinding? = null
38 private val binding get() = _binding!!
39
40 private val homeViewModel: HomeViewModel by activityViewModels()
41 private val addonViewModel: AddonViewModel by activityViewModels()
42
43 private val args by navArgs<AddonsFragmentArgs>()
44
45 override fun onCreate(savedInstanceState: Bundle?) {
46 super.onCreate(savedInstanceState)
47 addonViewModel.onOpenAddons(args.game)
48 enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
49 returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
50 reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
51 }
52
53 override fun onCreateView(
54 inflater: LayoutInflater,
55 container: ViewGroup?,
56 savedInstanceState: Bundle?
57 ): View {
58 _binding = FragmentAddonsBinding.inflate(inflater)
59 return binding.root
60 }
61
62 // This is using the correct scope, lint is just acting up
63 @SuppressLint("UnsafeRepeatOnLifecycleDetector")
64 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
65 super.onViewCreated(view, savedInstanceState)
66 homeViewModel.setNavigationVisibility(visible = false, animated = false)
67 homeViewModel.setStatusBarShadeVisibility(false)
68
69 binding.toolbarAddons.setNavigationOnClickListener {
70 binding.root.findNavController().popBackStack()
71 }
72
73 binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title)
74
75 binding.listAddons.apply {
76 layoutManager = LinearLayoutManager(requireContext())
77 adapter = AddonAdapter()
78 }
79
80 viewLifecycleOwner.lifecycleScope.apply {
81 launch {
82 repeatOnLifecycle(Lifecycle.State.STARTED) {
83 addonViewModel.addonList.collect {
84 (binding.listAddons.adapter as AddonAdapter).submitList(it)
85 }
86 }
87 }
88 launch {
89 repeatOnLifecycle(Lifecycle.State.STARTED) {
90 addonViewModel.showModInstallPicker.collect {
91 if (it) {
92 installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
93 addonViewModel.showModInstallPicker(false)
94 }
95 }
96 }
97 }
98 launch {
99 repeatOnLifecycle(Lifecycle.State.STARTED) {
100 addonViewModel.showModNoticeDialog.collect {
101 if (it) {
102 MessageDialogFragment.newInstance(
103 requireActivity(),
104 titleId = R.string.addon_notice,
105 descriptionId = R.string.addon_notice_description,
106 positiveAction = { addonViewModel.showModInstallPicker(true) }
107 ).show(parentFragmentManager, MessageDialogFragment.TAG)
108 addonViewModel.showModNoticeDialog(false)
109 }
110 }
111 }
112 }
113 }
114
115 binding.buttonInstall.setOnClickListener {
116 ContentTypeSelectionDialogFragment().show(
117 parentFragmentManager,
118 ContentTypeSelectionDialogFragment.TAG
119 )
120 }
121
122 setInsets()
123 }
124
125 override fun onResume() {
126 super.onResume()
127 addonViewModel.refreshAddons()
128 }
129
130 override fun onDestroy() {
131 super.onDestroy()
132 addonViewModel.onCloseAddons()
133 }
134
135 val installAddon =
136 registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
137 if (result == null) {
138 return@registerForActivityResult
139 }
140
141 val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result)
142 if (externalAddonDirectory == null) {
143 MessageDialogFragment.newInstance(
144 requireActivity(),
145 titleId = R.string.invalid_directory,
146 descriptionId = R.string.invalid_directory_description
147 ).show(parentFragmentManager, MessageDialogFragment.TAG)
148 return@registerForActivityResult
149 }
150
151 val isValid = externalAddonDirectory.listFiles()
152 .any { AddonUtil.validAddonDirectories.contains(it.name) }
153 val errorMessage = MessageDialogFragment.newInstance(
154 requireActivity(),
155 titleId = R.string.invalid_directory,
156 descriptionId = R.string.invalid_directory_description
157 )
158 if (isValid) {
159 IndeterminateProgressDialogFragment.newInstance(
160 requireActivity(),
161 R.string.installing_game_content,
162 false
163 ) {
164 val parentDirectoryName = externalAddonDirectory.name
165 val internalAddonDirectory =
166 File(args.game.addonDir + parentDirectoryName)
167 try {
168 externalAddonDirectory.copyFilesTo(internalAddonDirectory)
169 } catch (_: Exception) {
170 return@newInstance errorMessage
171 }
172 addonViewModel.refreshAddons()
173 return@newInstance getString(R.string.addon_installed_successfully)
174 }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
175 } else {
176 errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG)
177 }
178 }
179
180 private fun setInsets() =
181 ViewCompat.setOnApplyWindowInsetsListener(
182 binding.root
183 ) { _: View, windowInsets: WindowInsetsCompat ->
184 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
185 val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
186
187 val leftInsets = barInsets.left + cutoutInsets.left
188 val rightInsets = barInsets.right + cutoutInsets.right
189
190 val mlpToolbar = binding.toolbarAddons.layoutParams as ViewGroup.MarginLayoutParams
191 mlpToolbar.leftMargin = leftInsets
192 mlpToolbar.rightMargin = rightInsets
193 binding.toolbarAddons.layoutParams = mlpToolbar
194
195 val mlpAddonsList = binding.listAddons.layoutParams as ViewGroup.MarginLayoutParams
196 mlpAddonsList.leftMargin = leftInsets
197 mlpAddonsList.rightMargin = rightInsets
198 binding.listAddons.layoutParams = mlpAddonsList
199 binding.listAddons.updatePadding(
200 bottom = barInsets.bottom +
201 resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
202 )
203
204 val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
205 val mlpFab =
206 binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams
207 mlpFab.leftMargin = leftInsets + fabSpacing
208 mlpFab.rightMargin = rightInsets + fabSpacing
209 mlpFab.bottomMargin = barInsets.bottom + fabSpacing
210 binding.buttonInstall.layoutParams = mlpFab
211
212 windowInsets
213 }
214}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
new file mode 100644
index 000000000..c1d8b9ea5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
@@ -0,0 +1,68 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.DialogInterface
8import android.os.Bundle
9import androidx.fragment.app.DialogFragment
10import androidx.fragment.app.activityViewModels
11import androidx.preference.PreferenceManager
12import com.google.android.material.dialog.MaterialAlertDialogBuilder
13import org.yuzu.yuzu_emu.R
14import org.yuzu.yuzu_emu.YuzuApplication
15import org.yuzu.yuzu_emu.model.AddonViewModel
16import org.yuzu.yuzu_emu.ui.main.MainActivity
17
18class ContentTypeSelectionDialogFragment : DialogFragment() {
19 private val addonViewModel: AddonViewModel by activityViewModels()
20
21 private val preferences get() =
22 PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
23
24 private var selectedItem = 0
25
26 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
27 val launchOptions =
28 arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats))
29
30 if (savedInstanceState != null) {
31 selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
32 }
33
34 val mainActivity = requireActivity() as MainActivity
35 return MaterialAlertDialogBuilder(requireContext())
36 .setTitle(R.string.select_content_type)
37 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
38 when (selectedItem) {
39 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*"))
40 else -> {
41 if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) {
42 preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply()
43 addonViewModel.showModNoticeDialog(true)
44 return@setPositiveButton
45 }
46 addonViewModel.showModInstallPicker(true)
47 }
48 }
49 }
50 .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
51 selectedItem = i
52 }
53 .setNegativeButton(android.R.string.cancel, null)
54 .show()
55 }
56
57 override fun onSaveInstanceState(outState: Bundle) {
58 super.onSaveInstanceState(outState)
59 outState.putInt(SELECTED_ITEM, selectedItem)
60 }
61
62 companion object {
63 const val TAG = "ContentTypeSelectionDialogFragment"
64
65 private const val SELECTED_ITEM = "SelectedItem"
66 private const val MOD_NOTICE_SEEN = "ModNoticeSeen"
67 }
68}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
new file mode 100644
index 000000000..fa2a4c9f9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
@@ -0,0 +1,148 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.content.ClipData
7import android.content.ClipboardManager
8import android.content.Context
9import android.net.Uri
10import android.os.Build
11import android.os.Bundle
12import android.view.LayoutInflater
13import android.view.View
14import android.view.ViewGroup
15import android.widget.Toast
16import androidx.core.view.ViewCompat
17import androidx.core.view.WindowInsetsCompat
18import androidx.core.view.updatePadding
19import androidx.fragment.app.Fragment
20import androidx.fragment.app.activityViewModels
21import androidx.navigation.findNavController
22import androidx.navigation.fragment.navArgs
23import com.google.android.material.transition.MaterialSharedAxis
24import org.yuzu.yuzu_emu.R
25import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding
26import org.yuzu.yuzu_emu.model.HomeViewModel
27import org.yuzu.yuzu_emu.utils.GameMetadata
28
29class GameInfoFragment : Fragment() {
30 private var _binding: FragmentGameInfoBinding? = null
31 private val binding get() = _binding!!
32
33 private val homeViewModel: HomeViewModel by activityViewModels()
34
35 private val args by navArgs<GameInfoFragmentArgs>()
36
37 override fun onCreate(savedInstanceState: Bundle?) {
38 super.onCreate(savedInstanceState)
39 enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
40 returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
41 reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
42
43 // Check for an up-to-date version string
44 args.game.version = GameMetadata.getVersion(args.game.path, true)
45 }
46
47 override fun onCreateView(
48 inflater: LayoutInflater,
49 container: ViewGroup?,
50 savedInstanceState: Bundle?
51 ): View {
52 _binding = FragmentGameInfoBinding.inflate(inflater)
53 return binding.root
54 }
55
56 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
57 super.onViewCreated(view, savedInstanceState)
58 homeViewModel.setNavigationVisibility(visible = false, animated = false)
59 homeViewModel.setStatusBarShadeVisibility(false)
60
61 binding.apply {
62 toolbarInfo.title = args.game.title
63 toolbarInfo.setNavigationOnClickListener {
64 view.findNavController().popBackStack()
65 }
66
67 val pathString = Uri.parse(args.game.path).path ?: ""
68 path.setHint(R.string.path)
69 pathField.setText(pathString)
70 pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) }
71
72 programId.setHint(R.string.program_id)
73 programIdField.setText(args.game.programIdHex)
74 programIdField.setOnClickListener {
75 copyToClipboard(getString(R.string.program_id), args.game.programIdHex)
76 }
77
78 if (args.game.developer.isNotEmpty()) {
79 developer.setHint(R.string.developer)
80 developerField.setText(args.game.developer)
81 developerField.setOnClickListener {
82 copyToClipboard(getString(R.string.developer), args.game.developer)
83 }
84 } else {
85 developer.visibility = View.GONE
86 }
87
88 version.setHint(R.string.version)
89 versionField.setText(args.game.version)
90 versionField.setOnClickListener {
91 copyToClipboard(getString(R.string.version), args.game.version)
92 }
93
94 buttonCopy.setOnClickListener {
95 val details = """
96 ${args.game.title}
97 ${getString(R.string.path)} - $pathString
98 ${getString(R.string.program_id)} - ${args.game.programIdHex}
99 ${getString(R.string.developer)} - ${args.game.developer}
100 ${getString(R.string.version)} - ${args.game.version}
101 """.trimIndent()
102 copyToClipboard(args.game.title, details)
103 }
104 }
105
106 setInsets()
107 }
108
109 private fun copyToClipboard(label: String, body: String) {
110 val clipBoard =
111 requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
112 val clip = ClipData.newPlainText(label, body)
113 clipBoard.setPrimaryClip(clip)
114
115 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
116 Toast.makeText(
117 requireContext(),
118 R.string.copied_to_clipboard,
119 Toast.LENGTH_SHORT
120 ).show()
121 }
122 }
123
124 private fun setInsets() =
125 ViewCompat.setOnApplyWindowInsetsListener(
126 binding.root
127 ) { _: View, windowInsets: WindowInsetsCompat ->
128 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
129 val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
130
131 val leftInsets = barInsets.left + cutoutInsets.left
132 val rightInsets = barInsets.right + cutoutInsets.right
133
134 val mlpToolbar = binding.toolbarInfo.layoutParams as ViewGroup.MarginLayoutParams
135 mlpToolbar.leftMargin = leftInsets
136 mlpToolbar.rightMargin = rightInsets
137 binding.toolbarInfo.layoutParams = mlpToolbar
138
139 val mlpScrollAbout = binding.scrollInfo.layoutParams as ViewGroup.MarginLayoutParams
140 mlpScrollAbout.leftMargin = leftInsets
141 mlpScrollAbout.rightMargin = rightInsets
142 binding.scrollInfo.layoutParams = mlpScrollAbout
143
144 binding.contentInfo.updatePadding(bottom = barInsets.bottom)
145
146 windowInsets
147 }
148}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
new file mode 100644
index 000000000..485989e2e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
@@ -0,0 +1,418 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.os.Bundle
7import android.text.TextUtils
8import android.view.LayoutInflater
9import android.view.View
10import android.view.ViewGroup
11import android.widget.Toast
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.core.view.ViewCompat
14import androidx.core.view.WindowInsetsCompat
15import androidx.core.view.updatePadding
16import androidx.fragment.app.Fragment
17import androidx.fragment.app.activityViewModels
18import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.lifecycleScope
20import androidx.lifecycle.repeatOnLifecycle
21import androidx.navigation.findNavController
22import androidx.navigation.fragment.navArgs
23import androidx.recyclerview.widget.GridLayoutManager
24import com.google.android.material.transition.MaterialSharedAxis
25import kotlinx.coroutines.Dispatchers
26import kotlinx.coroutines.launch
27import kotlinx.coroutines.withContext
28import org.yuzu.yuzu_emu.HomeNavigationDirections
29import org.yuzu.yuzu_emu.R
30import org.yuzu.yuzu_emu.YuzuApplication
31import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
32import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding
33import org.yuzu.yuzu_emu.features.settings.model.Settings
34import org.yuzu.yuzu_emu.model.DriverViewModel
35import org.yuzu.yuzu_emu.model.GameProperty
36import org.yuzu.yuzu_emu.model.GamesViewModel
37import org.yuzu.yuzu_emu.model.HomeViewModel
38import org.yuzu.yuzu_emu.model.InstallableProperty
39import org.yuzu.yuzu_emu.model.SubmenuProperty
40import org.yuzu.yuzu_emu.model.TaskState
41import org.yuzu.yuzu_emu.utils.DirectoryInitialization
42import org.yuzu.yuzu_emu.utils.FileUtil
43import org.yuzu.yuzu_emu.utils.GameIconUtils
44import org.yuzu.yuzu_emu.utils.GpuDriverHelper
45import org.yuzu.yuzu_emu.utils.MemoryUtil
46import java.io.BufferedInputStream
47import java.io.BufferedOutputStream
48import java.io.File
49
50class GamePropertiesFragment : Fragment() {
51 private var _binding: FragmentGamePropertiesBinding? = null
52 private val binding get() = _binding!!
53
54 private val homeViewModel: HomeViewModel by activityViewModels()
55 private val gamesViewModel: GamesViewModel by activityViewModels()
56 private val driverViewModel: DriverViewModel by activityViewModels()
57
58 private val args by navArgs<GamePropertiesFragmentArgs>()
59
60 override fun onCreate(savedInstanceState: Bundle?) {
61 super.onCreate(savedInstanceState)
62 enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true)
63 returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false)
64 reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
65 }
66
67 override fun onCreateView(
68 inflater: LayoutInflater,
69 container: ViewGroup?,
70 savedInstanceState: Bundle?
71 ): View {
72 _binding = FragmentGamePropertiesBinding.inflate(layoutInflater)
73 return binding.root
74 }
75
76 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
77 super.onViewCreated(view, savedInstanceState)
78 homeViewModel.setNavigationVisibility(visible = false, animated = true)
79 homeViewModel.setStatusBarShadeVisibility(true)
80
81 binding.buttonBack.setOnClickListener {
82 view.findNavController().popBackStack()
83 }
84
85 GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen)
86 binding.title.text = args.game.title
87 binding.title.postDelayed(
88 {
89 binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE
90 binding.title.isSelected = true
91 },
92 3000
93 )
94
95 binding.buttonStart.setOnClickListener {
96 LaunchGameDialogFragment.newInstance(args.game)
97 .show(childFragmentManager, LaunchGameDialogFragment.TAG)
98 }
99
100 reloadList()
101
102 viewLifecycleOwner.lifecycleScope.launch {
103 repeatOnLifecycle(Lifecycle.State.STARTED) {
104 homeViewModel.openImportSaves.collect {
105 if (it) {
106 importSaves.launch(arrayOf("application/zip"))
107 homeViewModel.setOpenImportSaves(false)
108 }
109 }
110 }
111 }
112
113 setInsets()
114 }
115
116 override fun onDestroy() {
117 super.onDestroy()
118 gamesViewModel.reloadGames(true)
119 }
120
121 private fun reloadList() {
122 _binding ?: return
123
124 driverViewModel.updateDriverNameForGame(args.game)
125 val properties = mutableListOf<GameProperty>().apply {
126 add(
127 SubmenuProperty(
128 R.string.info,
129 R.string.info_description,
130 R.drawable.ic_info_outline
131 ) {
132 val action = GamePropertiesFragmentDirections
133 .actionPerGamePropertiesFragmentToGameInfoFragment(args.game)
134 binding.root.findNavController().navigate(action)
135 }
136 )
137 add(
138 SubmenuProperty(
139 R.string.preferences_settings,
140 R.string.per_game_settings_description,
141 R.drawable.ic_settings
142 ) {
143 val action = HomeNavigationDirections.actionGlobalSettingsActivity(
144 args.game,
145 Settings.MenuTag.SECTION_ROOT
146 )
147 binding.root.findNavController().navigate(action)
148 }
149 )
150
151 if (!args.game.isHomebrew) {
152 add(
153 SubmenuProperty(
154 R.string.add_ons,
155 R.string.add_ons_description,
156 R.drawable.ic_edit
157 ) {
158 val action = GamePropertiesFragmentDirections
159 .actionPerGamePropertiesFragmentToAddonsFragment(args.game)
160 binding.root.findNavController().navigate(action)
161 }
162 )
163 add(
164 InstallableProperty(
165 R.string.save_data,
166 R.string.save_data_description,
167 {
168 MessageDialogFragment.newInstance(
169 requireActivity(),
170 titleId = R.string.import_save_warning,
171 descriptionId = R.string.import_save_warning_description,
172 positiveAction = { homeViewModel.setOpenImportSaves(true) }
173 ).show(parentFragmentManager, MessageDialogFragment.TAG)
174 },
175 if (File(args.game.saveDir).exists()) {
176 { exportSaves.launch(args.game.saveZipName) }
177 } else {
178 null
179 }
180 )
181 )
182
183 val saveDirFile = File(args.game.saveDir)
184 if (saveDirFile.exists()) {
185 add(
186 SubmenuProperty(
187 R.string.delete_save_data,
188 R.string.delete_save_data_description,
189 R.drawable.ic_delete,
190 action = {
191 MessageDialogFragment.newInstance(
192 requireActivity(),
193 titleId = R.string.delete_save_data,
194 descriptionId = R.string.delete_save_data_warning_description,
195 positiveAction = {
196 File(args.game.saveDir).deleteRecursively()
197 Toast.makeText(
198 YuzuApplication.appContext,
199 R.string.save_data_deleted_successfully,
200 Toast.LENGTH_SHORT
201 ).show()
202 reloadList()
203 }
204 ).show(parentFragmentManager, MessageDialogFragment.TAG)
205 }
206 )
207 )
208 }
209
210 val shaderCacheDir = File(
211 DirectoryInitialization.userDirectory +
212 "/shader/" + args.game.settingsName.lowercase()
213 )
214 if (shaderCacheDir.exists()) {
215 add(
216 SubmenuProperty(
217 R.string.clear_shader_cache,
218 R.string.clear_shader_cache_description,
219 R.drawable.ic_delete,
220 {
221 if (shaderCacheDir.exists()) {
222 val bytes = shaderCacheDir.walkTopDown().filter { it.isFile }
223 .map { it.length() }.sum()
224 MemoryUtil.bytesToSizeUnit(bytes.toFloat())
225 } else {
226 MemoryUtil.bytesToSizeUnit(0f)
227 }
228 }
229 ) {
230 shaderCacheDir.deleteRecursively()
231 Toast.makeText(
232 YuzuApplication.appContext,
233 R.string.cleared_shaders_successfully,
234 Toast.LENGTH_SHORT
235 ).show()
236 reloadList()
237 }
238 )
239 }
240 }
241 }
242 binding.listProperties.apply {
243 layoutManager =
244 GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns))
245 adapter = GamePropertiesAdapter(viewLifecycleOwner, properties)
246 }
247 }
248
249 override fun onResume() {
250 super.onResume()
251 driverViewModel.updateDriverNameForGame(args.game)
252 }
253
254 private fun setInsets() =
255 ViewCompat.setOnApplyWindowInsetsListener(
256 binding.root
257 ) { _: View, windowInsets: WindowInsetsCompat ->
258 val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
259 val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
260
261 val leftInsets = barInsets.left + cutoutInsets.left
262 val rightInsets = barInsets.right + cutoutInsets.right
263
264 val smallLayout = resources.getBoolean(R.bool.small_layout)
265 if (smallLayout) {
266 val mlpListAll =
267 binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
268 mlpListAll.leftMargin = leftInsets
269 mlpListAll.rightMargin = rightInsets
270 binding.listAll.layoutParams = mlpListAll
271 } else {
272 if (ViewCompat.getLayoutDirection(binding.root) ==
273 ViewCompat.LAYOUT_DIRECTION_LTR
274 ) {
275 val mlpListAll =
276 binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
277 mlpListAll.rightMargin = rightInsets
278 binding.listAll.layoutParams = mlpListAll
279
280 val mlpIconLayout =
281 binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
282 mlpIconLayout.topMargin = barInsets.top
283 mlpIconLayout.leftMargin = leftInsets
284 binding.iconLayout!!.layoutParams = mlpIconLayout
285 } else {
286 val mlpListAll =
287 binding.listAll.layoutParams as ViewGroup.MarginLayoutParams
288 mlpListAll.leftMargin = leftInsets
289 binding.listAll.layoutParams = mlpListAll
290
291 val mlpIconLayout =
292 binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams
293 mlpIconLayout.topMargin = barInsets.top
294 mlpIconLayout.rightMargin = rightInsets
295 binding.iconLayout!!.layoutParams = mlpIconLayout
296 }
297 }
298
299 val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
300 val mlpFab =
301 binding.buttonStart.layoutParams as ViewGroup.MarginLayoutParams
302 mlpFab.leftMargin = leftInsets + fabSpacing
303 mlpFab.rightMargin = rightInsets + fabSpacing
304 mlpFab.bottomMargin = barInsets.bottom + fabSpacing
305 binding.buttonStart.layoutParams = mlpFab
306
307 binding.layoutAll.updatePadding(
308 top = barInsets.top,
309 bottom = barInsets.bottom +
310 resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
311 )
312
313 windowInsets
314 }
315
316 private val importSaves =
317 registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
318 if (result == null) {
319 return@registerForActivityResult
320 }
321
322 val inputZip = requireContext().contentResolver.openInputStream(result)
323 val savesFolder = File(args.game.saveDir)
324 val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
325 cacheSaveDir.mkdir()
326
327 if (inputZip == null) {
328 Toast.makeText(
329 YuzuApplication.appContext,
330 getString(R.string.fatal_error),
331 Toast.LENGTH_LONG
332 ).show()
333 return@registerForActivityResult
334 }
335
336 IndeterminateProgressDialogFragment.newInstance(
337 requireActivity(),
338 R.string.save_files_importing,
339 false
340 ) {
341 try {
342 FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
343 val files = cacheSaveDir.listFiles()
344 var savesFolderFile: File? = null
345 if (files != null) {
346 val savesFolderName = args.game.programIdHex
347 for (file in files) {
348 if (file.isDirectory && file.name == savesFolderName) {
349 savesFolderFile = file
350 break
351 }
352 }
353 }
354
355 if (savesFolderFile != null) {
356 savesFolder.deleteRecursively()
357 savesFolder.mkdir()
358 savesFolderFile.copyRecursively(savesFolder)
359 savesFolderFile.deleteRecursively()
360 }
361
362 withContext(Dispatchers.Main) {
363 if (savesFolderFile == null) {
364 MessageDialogFragment.newInstance(
365 requireActivity(),
366 titleId = R.string.save_file_invalid_zip_structure,
367 descriptionId = R.string.save_file_invalid_zip_structure_description
368 ).show(parentFragmentManager, MessageDialogFragment.TAG)
369 return@withContext
370 }
371 Toast.makeText(
372 YuzuApplication.appContext,
373 getString(R.string.save_file_imported_success),
374 Toast.LENGTH_LONG
375 ).show()
376 reloadList()
377 }
378
379 cacheSaveDir.deleteRecursively()
380 } catch (e: Exception) {
381 Toast.makeText(
382 YuzuApplication.appContext,
383 getString(R.string.fatal_error),
384 Toast.LENGTH_LONG
385 ).show()
386 }
387 }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
388 }
389
390 /**
391 * Exports the save file located in the given folder path by creating a zip file and opening a
392 * file picker to save.
393 */
394 private val exportSaves = registerForActivityResult(
395 ActivityResultContracts.CreateDocument("application/zip")
396 ) { result ->
397 if (result == null) {
398 return@registerForActivityResult
399 }
400
401 IndeterminateProgressDialogFragment.newInstance(
402 requireActivity(),
403 R.string.save_files_exporting,
404 false
405 ) {
406 val saveLocation = args.game.saveDir
407 val zipResult = FileUtil.zipFromInternalStorage(
408 File(saveLocation),
409 saveLocation.replaceAfterLast("/", ""),
410 BufferedOutputStream(requireContext().contentResolver.openOutputStream(result))
411 )
412 return@newInstance when (zipResult) {
413 TaskState.Completed -> getString(R.string.export_success)
414 TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
415 }
416 }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
417 }
418}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
index 7e467814d..8847e5531 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -122,7 +122,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
122 activity: FragmentActivity, 122 activity: FragmentActivity,
123 titleId: Int, 123 titleId: Int,
124 cancellable: Boolean = false, 124 cancellable: Boolean = false,
125 task: () -> Any 125 task: suspend () -> Any
126 ): IndeterminateProgressDialogFragment { 126 ): IndeterminateProgressDialogFragment {
127 val dialog = IndeterminateProgressDialogFragment() 127 val dialog = IndeterminateProgressDialogFragment()
128 val args = Bundle() 128 val args = Bundle()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
new file mode 100644
index 000000000..f653826a6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
@@ -0,0 +1,61 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.fragments
5
6import android.app.Dialog
7import android.content.DialogInterface
8import android.os.Bundle
9import androidx.fragment.app.DialogFragment
10import androidx.navigation.fragment.findNavController
11import com.google.android.material.dialog.MaterialAlertDialogBuilder
12import org.yuzu.yuzu_emu.HomeNavigationDirections
13import org.yuzu.yuzu_emu.R
14import org.yuzu.yuzu_emu.model.Game
15import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
16
17class LaunchGameDialogFragment : DialogFragment() {
18 private var selectedItem = 0
19
20 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
21 val game = requireArguments().parcelable<Game>(GAME)
22 val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom))
23
24 if (savedInstanceState != null) {
25 selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
26 }
27
28 return MaterialAlertDialogBuilder(requireContext())
29 .setTitle(R.string.launch_options)
30 .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
31 val action = HomeNavigationDirections
32 .actionGlobalEmulationActivity(game, selectedItem != 0)
33 requireParentFragment().findNavController().navigate(action)
34 }
35 .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
36 selectedItem = i
37 }
38 .setNegativeButton(android.R.string.cancel, null)
39 .show()
40 }
41
42 override fun onSaveInstanceState(outState: Bundle) {
43 super.onSaveInstanceState(outState)
44 outState.putInt(SELECTED_ITEM, selectedItem)
45 }
46
47 companion object {
48 const val TAG = "LaunchGameDialogFragment"
49
50 const val GAME = "Game"
51 const val SELECTED_ITEM = "SelectedItem"
52
53 fun newInstance(game: Game): LaunchGameDialogFragment {
54 val args = Bundle()
55 args.putParcelable(GAME, game)
56 val fragment = LaunchGameDialogFragment()
57 fragment.arguments = args
58 return fragment
59 }
60 }
61}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
index a6183d19e..32062b6fe 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
@@ -27,30 +27,31 @@ class MessageDialogFragment : DialogFragment() {
27 val descriptionString = requireArguments().getString(DESCRIPTION_STRING)!! 27 val descriptionString = requireArguments().getString(DESCRIPTION_STRING)!!
28 val helpLinkId = requireArguments().getInt(HELP_LINK) 28 val helpLinkId = requireArguments().getInt(HELP_LINK)
29 29
30 val dialog = MaterialAlertDialogBuilder(requireContext()) 30 val builder = MaterialAlertDialogBuilder(requireContext())
31 .setPositiveButton(R.string.close, null)
32 31
33 if (titleId != 0) dialog.setTitle(titleId) 32 if (messageDialogViewModel.positiveAction == null) {
34 if (titleString.isNotEmpty()) dialog.setTitle(titleString) 33 builder.setPositiveButton(R.string.close, null)
34 } else {
35 builder.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
36 messageDialogViewModel.positiveAction?.invoke()
37 }.setNegativeButton(android.R.string.cancel, null)
38 }
39
40 if (titleId != 0) builder.setTitle(titleId)
41 if (titleString.isNotEmpty()) builder.setTitle(titleString)
35 42
36 if (descriptionId != 0) { 43 if (descriptionId != 0) {
37 dialog.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY)) 44 builder.setMessage(Html.fromHtml(getString(descriptionId), Html.FROM_HTML_MODE_LEGACY))
38 } 45 }
39 if (descriptionString.isNotEmpty()) dialog.setMessage(descriptionString) 46 if (descriptionString.isNotEmpty()) builder.setMessage(descriptionString)
40 47
41 if (helpLinkId != 0) { 48 if (helpLinkId != 0) {
42 dialog.setNeutralButton(R.string.learn_more) { _, _ -> 49 builder.setNeutralButton(R.string.learn_more) { _, _ ->
43 openLink(getString(helpLinkId)) 50 openLink(getString(helpLinkId))
44 } 51 }
45 } 52 }
46 53
47 return dialog.show() 54 return builder.show()
48 }
49
50 override fun onDismiss(dialog: DialogInterface) {
51 super.onDismiss(dialog)
52 messageDialogViewModel.dismissAction.invoke()
53 messageDialogViewModel.clear()
54 } 55 }
55 56
56 private fun openLink(link: String) { 57 private fun openLink(link: String) {
@@ -74,7 +75,7 @@ class MessageDialogFragment : DialogFragment() {
74 descriptionId: Int = 0, 75 descriptionId: Int = 0,
75 descriptionString: String = "", 76 descriptionString: String = "",
76 helpLinkId: Int = 0, 77 helpLinkId: Int = 0,
77 dismissAction: () -> Unit = {} 78 positiveAction: (() -> Unit)? = null
78 ): MessageDialogFragment { 79 ): MessageDialogFragment {
79 val dialog = MessageDialogFragment() 80 val dialog = MessageDialogFragment()
80 val bundle = Bundle() 81 val bundle = Bundle()
@@ -85,8 +86,10 @@ class MessageDialogFragment : DialogFragment() {
85 putString(DESCRIPTION_STRING, descriptionString) 86 putString(DESCRIPTION_STRING, descriptionString)
86 putInt(HELP_LINK, helpLinkId) 87 putInt(HELP_LINK, helpLinkId)
87 } 88 }
88 ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction = 89 ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply {
89 dismissAction 90 clear()
91 this.positiveAction = positiveAction
92 }
90 dialog.arguments = bundle 93 dialog.arguments = bundle
91 return dialog 94 return dialog
92 } 95 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
index 2dbca76a5..3ac054d8f 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
@@ -60,7 +60,9 @@ class SearchFragment : Fragment() {
60 // This is using the correct scope, lint is just acting up 60 // This is using the correct scope, lint is just acting up
61 @SuppressLint("UnsafeRepeatOnLifecycleDetector") 61 @SuppressLint("UnsafeRepeatOnLifecycleDetector")
62 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 62 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
63 homeViewModel.setNavigationVisibility(visible = true, animated = false) 63 super.onViewCreated(view, savedInstanceState)
64 homeViewModel.setNavigationVisibility(visible = true, animated = true)
65 homeViewModel.setStatusBarShadeVisibility(true)
64 preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) 66 preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
65 67
66 if (savedInstanceState != null) { 68 if (savedInstanceState != null) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
new file mode 100644
index 000000000..ed79a8b02
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
@@ -0,0 +1,10 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.model
5
6data class Addon(
7 var enabled: Boolean,
8 val title: String,
9 val version: String
10)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
new file mode 100644
index 000000000..075252f5b
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
@@ -0,0 +1,83 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.model
5
6import androidx.lifecycle.ViewModel
7import androidx.lifecycle.viewModelScope
8import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.launch
12import kotlinx.coroutines.withContext
13import org.yuzu.yuzu_emu.NativeLibrary
14import org.yuzu.yuzu_emu.utils.NativeConfig
15import java.util.concurrent.atomic.AtomicBoolean
16
17class AddonViewModel : ViewModel() {
18 private val _addonList = MutableStateFlow(mutableListOf<Addon>())
19 val addonList get() = _addonList.asStateFlow()
20
21 private val _showModInstallPicker = MutableStateFlow(false)
22 val showModInstallPicker get() = _showModInstallPicker.asStateFlow()
23
24 private val _showModNoticeDialog = MutableStateFlow(false)
25 val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow()
26
27 var game: Game? = null
28
29 private val isRefreshing = AtomicBoolean(false)
30
31 fun onOpenAddons(game: Game) {
32 this.game = game
33 refreshAddons()
34 }
35
36 fun refreshAddons() {
37 if (isRefreshing.get() || game == null) {
38 return
39 }
40 isRefreshing.set(true)
41 viewModelScope.launch {
42 withContext(Dispatchers.IO) {
43 val addonList = mutableListOf<Addon>()
44 val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId)
45 NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach {
46 val name = it.first.replace("[D] ", "")
47 addonList.add(Addon(!disabledAddons.contains(name), name, it.second))
48 }
49 addonList.sortBy { it.title }
50 _addonList.value = addonList
51 isRefreshing.set(false)
52 }
53 }
54 }
55
56 fun onCloseAddons() {
57 if (_addonList.value.isEmpty()) {
58 return
59 }
60
61 NativeConfig.setDisabledAddons(
62 game!!.programId,
63 _addonList.value.mapNotNull {
64 if (it.enabled) {
65 null
66 } else {
67 it.title
68 }
69 }.toTypedArray()
70 )
71 NativeConfig.saveGlobalConfig()
72 _addonList.value.clear()
73 game = null
74 }
75
76 fun showModInstallPicker(install: Boolean) {
77 _showModInstallPicker.value = install
78 }
79
80 fun showModNoticeDialog(show: Boolean) {
81 _showModNoticeDialog.value = show
82 }
83}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
index 2fa3ab31b..ac642c16e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -3,10 +3,18 @@
3 3
4package org.yuzu.yuzu_emu.model 4package org.yuzu.yuzu_emu.model
5 5
6import android.net.Uri
6import android.os.Parcelable 7import android.os.Parcelable
7import java.util.HashSet 8import java.util.HashSet
8import kotlinx.parcelize.Parcelize 9import kotlinx.parcelize.Parcelize
9import kotlinx.serialization.Serializable 10import kotlinx.serialization.Serializable
11import org.yuzu.yuzu_emu.NativeLibrary
12import org.yuzu.yuzu_emu.R
13import org.yuzu.yuzu_emu.YuzuApplication
14import org.yuzu.yuzu_emu.utils.DirectoryInitialization
15import org.yuzu.yuzu_emu.utils.FileUtil
16import java.time.LocalDateTime
17import java.time.format.DateTimeFormatter
10 18
11@Parcelize 19@Parcelize
12@Serializable 20@Serializable
@@ -15,12 +23,44 @@ class Game(
15 val path: String, 23 val path: String,
16 val programId: String = "", 24 val programId: String = "",
17 val developer: String = "", 25 val developer: String = "",
18 val version: String = "", 26 var version: String = "",
19 val isHomebrew: Boolean = false 27 val isHomebrew: Boolean = false
20) : Parcelable { 28) : Parcelable {
21 val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime" 29 val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime"
22 val keyLastPlayedTime get() = "${path}_LastPlayed" 30 val keyLastPlayedTime get() = "${path}_LastPlayed"
23 31
32 val settingsName: String
33 get() {
34 val programIdLong = programId.toLong()
35 return if (programIdLong == 0L) {
36 FileUtil.getFilename(Uri.parse(path))
37 } else {
38 "0" + programIdLong.toString(16).uppercase()
39 }
40 }
41
42 val programIdHex: String
43 get() {
44 val programIdLong = programId.toLong()
45 return if (programIdLong == 0L) {
46 "0"
47 } else {
48 "0" + programIdLong.toString(16).uppercase()
49 }
50 }
51
52 val saveZipName: String
53 get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${
54 LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
55 }.zip"
56
57 val saveDir: String
58 get() = DirectoryInitialization.userDirectory + "/nand" +
59 NativeLibrary.getSavePath(programId)
60
61 val addonDir: String
62 get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/"
63
24 override fun equals(other: Any?): Boolean { 64 override fun equals(other: Any?): Boolean {
25 if (other !is Game) { 65 if (other !is Game) {
26 return false 66 return false
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
new file mode 100644
index 000000000..bb3df5bd0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
@@ -0,0 +1,34 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.model
5
6import androidx.annotation.DrawableRes
7import androidx.annotation.StringRes
8import kotlinx.coroutines.flow.StateFlow
9
10interface GameProperty {
11 @get:StringRes
12 val titleId: Int
13 get() = -1
14
15 @get:StringRes
16 val descriptionId: Int
17 get() = -1
18}
19
20data class SubmenuProperty(
21 override val titleId: Int,
22 override val descriptionId: Int,
23 @DrawableRes val iconId: Int,
24 val details: (() -> String)? = null,
25 val detailsFlow: StateFlow<String>? = null,
26 val action: () -> Unit
27) : GameProperty
28
29data class InstallableProperty(
30 override val titleId: Int,
31 override val descriptionId: Int,
32 val install: (() -> Unit)? = null,
33 val export: (() -> Unit)? = null
34) : GameProperty
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
index 07e65b028..d801db105 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
@@ -3,6 +3,7 @@
3 3
4package org.yuzu.yuzu_emu.model 4package org.yuzu.yuzu_emu.model
5 5
6import android.net.Uri
6import androidx.lifecycle.ViewModel 7import androidx.lifecycle.ViewModel
7import kotlinx.coroutines.flow.MutableStateFlow 8import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow 9import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +22,12 @@ class HomeViewModel : ViewModel() {
21 private val _gamesDirSelected = MutableStateFlow(false) 22 private val _gamesDirSelected = MutableStateFlow(false)
22 val gamesDirSelected get() = _gamesDirSelected.asStateFlow() 23 val gamesDirSelected get() = _gamesDirSelected.asStateFlow()
23 24
25 private val _openImportSaves = MutableStateFlow(false)
26 val openImportSaves get() = _openImportSaves.asStateFlow()
27
28 private val _contentToInstall = MutableStateFlow<List<Uri>?>(null)
29 val contentToInstall get() = _contentToInstall.asStateFlow()
30
24 var navigatedToSetup = false 31 var navigatedToSetup = false
25 32
26 fun setNavigationVisibility(visible: Boolean, animated: Boolean) { 33 fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
@@ -44,4 +51,12 @@ class HomeViewModel : ViewModel() {
44 fun setGamesDirSelected(selected: Boolean) { 51 fun setGamesDirSelected(selected: Boolean) {
45 _gamesDirSelected.value = selected 52 _gamesDirSelected.value = selected
46 } 53 }
54
55 fun setOpenImportSaves(import: Boolean) {
56 _openImportSaves.value = import
57 }
58
59 fun setContentToInstall(documents: List<Uri>?) {
60 _contentToInstall.value = documents
61 }
47} 62}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
index 36ffd08d2..641c5cb17 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
@@ -6,9 +6,9 @@ package org.yuzu.yuzu_emu.model
6import androidx.lifecycle.ViewModel 6import androidx.lifecycle.ViewModel
7 7
8class MessageDialogViewModel : ViewModel() { 8class MessageDialogViewModel : ViewModel() {
9 var dismissAction: () -> Unit = {} 9 var positiveAction: (() -> Unit)? = null
10 10
11 fun clear() { 11 fun clear() {
12 dismissAction = {} 12 positiveAction = null
13 } 13 }
14} 14}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
index 16a794dee..e59c95733 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -23,7 +23,7 @@ class TaskViewModel : ViewModel() {
23 val cancelled: StateFlow<Boolean> get() = _cancelled 23 val cancelled: StateFlow<Boolean> get() = _cancelled
24 private val _cancelled = MutableStateFlow(false) 24 private val _cancelled = MutableStateFlow(false)
25 25
26 lateinit var task: () -> Any 26 lateinit var task: suspend () -> Any
27 27
28 fun clear() { 28 fun clear() {
29 _result.value = Any() 29 _result.value = Any()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
index 805b89b31..d5acf8479 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
@@ -19,7 +19,7 @@ import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.lifecycleScope 19import androidx.lifecycle.lifecycleScope
20import androidx.lifecycle.repeatOnLifecycle 20import androidx.lifecycle.repeatOnLifecycle
21import com.google.android.material.color.MaterialColors 21import com.google.android.material.color.MaterialColors
22import com.google.android.material.transition.MaterialFadeThrough 22import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.launch 23import kotlinx.coroutines.launch
24import org.yuzu.yuzu_emu.R 24import org.yuzu.yuzu_emu.R
25import org.yuzu.yuzu_emu.adapters.GameAdapter 25import org.yuzu.yuzu_emu.adapters.GameAdapter
@@ -35,11 +35,6 @@ class GamesFragment : Fragment() {
35 private val gamesViewModel: GamesViewModel by activityViewModels() 35 private val gamesViewModel: GamesViewModel by activityViewModels()
36 private val homeViewModel: HomeViewModel by activityViewModels() 36 private val homeViewModel: HomeViewModel by activityViewModels()
37 37
38 override fun onCreate(savedInstanceState: Bundle?) {
39 super.onCreate(savedInstanceState)
40 enterTransition = MaterialFadeThrough()
41 }
42
43 override fun onCreateView( 38 override fun onCreateView(
44 inflater: LayoutInflater, 39 inflater: LayoutInflater,
45 container: ViewGroup?, 40 container: ViewGroup?,
@@ -52,7 +47,9 @@ class GamesFragment : Fragment() {
52 // This is using the correct scope, lint is just acting up 47 // This is using the correct scope, lint is just acting up
53 @SuppressLint("UnsafeRepeatOnLifecycleDetector") 48 @SuppressLint("UnsafeRepeatOnLifecycleDetector")
54 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 49 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
55 homeViewModel.setNavigationVisibility(visible = true, animated = false) 50 super.onViewCreated(view, savedInstanceState)
51 homeViewModel.setNavigationVisibility(visible = true, animated = true)
52 homeViewModel.setStatusBarShadeVisibility(true)
56 53
57 binding.gridGames.apply { 54 binding.gridGames.apply {
58 layoutManager = AutofitGridLayoutManager( 55 layoutManager = AutofitGridLayoutManager(
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 16323a316..09ddd1bbd 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -43,7 +43,7 @@ import org.yuzu.yuzu_emu.features.settings.model.Settings
43import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment 43import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
44import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment 44import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
45import org.yuzu.yuzu_emu.fragments.MessageDialogFragment 45import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
46import org.yuzu.yuzu_emu.getPublicFilesDir 46import org.yuzu.yuzu_emu.model.AddonViewModel
47import org.yuzu.yuzu_emu.model.GamesViewModel 47import org.yuzu.yuzu_emu.model.GamesViewModel
48import org.yuzu.yuzu_emu.model.HomeViewModel 48import org.yuzu.yuzu_emu.model.HomeViewModel
49import org.yuzu.yuzu_emu.model.TaskState 49import org.yuzu.yuzu_emu.model.TaskState
@@ -60,15 +60,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
60 private val homeViewModel: HomeViewModel by viewModels() 60 private val homeViewModel: HomeViewModel by viewModels()
61 private val gamesViewModel: GamesViewModel by viewModels() 61 private val gamesViewModel: GamesViewModel by viewModels()
62 private val taskViewModel: TaskViewModel by viewModels() 62 private val taskViewModel: TaskViewModel by viewModels()
63 private val addonViewModel: AddonViewModel by viewModels()
63 64
64 override var themeId: Int = 0 65 override var themeId: Int = 0
65 66
66 private val savesFolder
67 get() = "${getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
68
69 // Get first subfolder in saves folder (should be the user folder)
70 val savesFolderRoot get() = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
71
72 override fun onCreate(savedInstanceState: Bundle?) { 67 override fun onCreate(savedInstanceState: Bundle?) {
73 val splashScreen = installSplashScreen() 68 val splashScreen = installSplashScreen()
74 splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } 69 splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -145,6 +140,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
145 homeViewModel.statusBarShadeVisible.collect { showStatusBarShade(it) } 140 homeViewModel.statusBarShadeVisible.collect { showStatusBarShade(it) }
146 } 141 }
147 } 142 }
143 launch {
144 repeatOnLifecycle(Lifecycle.State.CREATED) {
145 homeViewModel.contentToInstall.collect {
146 if (it != null) {
147 installContent(it)
148 homeViewModel.setContentToInstall(null)
149 }
150 }
151 }
152 }
148 } 153 }
149 154
150 // Dismiss previous notifications (should not happen unless a crash occurred) 155 // Dismiss previous notifications (should not happen unless a crash occurred)
@@ -468,110 +473,150 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
468 val installGameUpdate = registerForActivityResult( 473 val installGameUpdate = registerForActivityResult(
469 ActivityResultContracts.OpenMultipleDocuments() 474 ActivityResultContracts.OpenMultipleDocuments()
470 ) { documents: List<Uri> -> 475 ) { documents: List<Uri> ->
471 if (documents.isNotEmpty()) { 476 if (documents.isEmpty()) {
472 IndeterminateProgressDialogFragment.newInstance( 477 return@registerForActivityResult
473 this@MainActivity, 478 }
474 R.string.installing_game_content
475 ) {
476 var installSuccess = 0
477 var installOverwrite = 0
478 var errorBaseGame = 0
479 var errorExtension = 0
480 var errorOther = 0
481 documents.forEach {
482 when (
483 NativeLibrary.installFileToNand(
484 it.toString(),
485 FileUtil.getExtension(it)
486 )
487 ) {
488 NativeLibrary.InstallFileToNandResult.Success -> {
489 installSuccess += 1
490 }
491 479
492 NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> { 480 if (addonViewModel.game == null) {
493 installOverwrite += 1 481 installContent(documents)
494 } 482 return@registerForActivityResult
483 }
495 484
496 NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> { 485 IndeterminateProgressDialogFragment.newInstance(
497 errorBaseGame += 1 486 this@MainActivity,
498 } 487 R.string.verifying_content,
488 false
489 ) {
490 var updatesMatchProgram = true
491 for (document in documents) {
492 val valid = NativeLibrary.doesUpdateMatchProgram(
493 addonViewModel.game!!.programId,
494 document.toString()
495 )
496 if (!valid) {
497 updatesMatchProgram = false
498 break
499 }
500 }
499 501
500 NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> { 502 if (updatesMatchProgram) {
501 errorExtension += 1 503 homeViewModel.setContentToInstall(documents)
502 } 504 } else {
505 MessageDialogFragment.newInstance(
506 this@MainActivity,
507 titleId = R.string.content_install_notice,
508 descriptionId = R.string.content_install_notice_description,
509 positiveAction = { homeViewModel.setContentToInstall(documents) }
510 )
511 }
512 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
513 }
503 514
504 else -> { 515 private fun installContent(documents: List<Uri>) {
505 errorOther += 1 516 IndeterminateProgressDialogFragment.newInstance(
506 } 517 this@MainActivity,
518 R.string.installing_game_content
519 ) {
520 var installSuccess = 0
521 var installOverwrite = 0
522 var errorBaseGame = 0
523 var errorExtension = 0
524 var errorOther = 0
525 documents.forEach {
526 when (
527 NativeLibrary.installFileToNand(
528 it.toString(),
529 FileUtil.getExtension(it)
530 )
531 ) {
532 NativeLibrary.InstallFileToNandResult.Success -> {
533 installSuccess += 1
534 }
535
536 NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
537 installOverwrite += 1
538 }
539
540 NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
541 errorBaseGame += 1
542 }
543
544 NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
545 errorExtension += 1
546 }
547
548 else -> {
549 errorOther += 1
507 } 550 }
508 } 551 }
552 }
509 553
510 val separator = System.getProperty("line.separator") ?: "\n" 554 addonViewModel.refreshAddons()
511 val installResult = StringBuilder() 555
512 if (installSuccess > 0) { 556 val separator = System.getProperty("line.separator") ?: "\n"
513 installResult.append( 557 val installResult = StringBuilder()
514 getString( 558 if (installSuccess > 0) {
515 R.string.install_game_content_success_install, 559 installResult.append(
516 installSuccess 560 getString(
517 ) 561 R.string.install_game_content_success_install,
562 installSuccess
563 )
564 )
565 installResult.append(separator)
566 }
567 if (installOverwrite > 0) {
568 installResult.append(
569 getString(
570 R.string.install_game_content_success_overwrite,
571 installOverwrite
518 ) 572 )
573 )
574 installResult.append(separator)
575 }
576 val errorTotal: Int = errorBaseGame + errorExtension + errorOther
577 if (errorTotal > 0) {
578 installResult.append(separator)
579 installResult.append(
580 getString(
581 R.string.install_game_content_failed_count,
582 errorTotal
583 )
584 )
585 installResult.append(separator)
586 if (errorBaseGame > 0) {
519 installResult.append(separator) 587 installResult.append(separator)
520 }
521 if (installOverwrite > 0) {
522 installResult.append( 588 installResult.append(
523 getString( 589 getString(R.string.install_game_content_failure_base)
524 R.string.install_game_content_success_overwrite,
525 installOverwrite
526 )
527 ) 590 )
528 installResult.append(separator) 591 installResult.append(separator)
529 } 592 }
530 val errorTotal: Int = errorBaseGame + errorExtension + errorOther 593 if (errorExtension > 0) {
531 if (errorTotal > 0) {
532 installResult.append(separator) 594 installResult.append(separator)
533 installResult.append( 595 installResult.append(
534 getString( 596 getString(R.string.install_game_content_failure_file_extension)
535 R.string.install_game_content_failed_count,
536 errorTotal
537 )
538 ) 597 )
539 installResult.append(separator) 598 installResult.append(separator)
540 if (errorBaseGame > 0) { 599 }
541 installResult.append(separator) 600 if (errorOther > 0) {
542 installResult.append( 601 installResult.append(
543 getString(R.string.install_game_content_failure_base) 602 getString(R.string.install_game_content_failure_description)
544 )
545 installResult.append(separator)
546 }
547 if (errorExtension > 0) {
548 installResult.append(separator)
549 installResult.append(
550 getString(R.string.install_game_content_failure_file_extension)
551 )
552 installResult.append(separator)
553 }
554 if (errorOther > 0) {
555 installResult.append(
556 getString(R.string.install_game_content_failure_description)
557 )
558 installResult.append(separator)
559 }
560 return@newInstance MessageDialogFragment.newInstance(
561 this,
562 titleId = R.string.install_game_content_failure,
563 descriptionString = installResult.toString().trim(),
564 helpLinkId = R.string.install_game_content_help_link
565 )
566 } else {
567 return@newInstance MessageDialogFragment.newInstance(
568 this,
569 titleId = R.string.install_game_content_success,
570 descriptionString = installResult.toString().trim()
571 ) 603 )
604 installResult.append(separator)
572 } 605 }
573 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 606 return@newInstance MessageDialogFragment.newInstance(
574 } 607 this,
608 titleId = R.string.install_game_content_failure,
609 descriptionString = installResult.toString().trim(),
610 helpLinkId = R.string.install_game_content_help_link
611 )
612 } else {
613 return@newInstance MessageDialogFragment.newInstance(
614 this,
615 titleId = R.string.install_game_content_success,
616 descriptionString = installResult.toString().trim()
617 )
618 }
619 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
575 } 620 }
576 621
577 val exportUserData = registerForActivityResult( 622 val exportUserData = registerForActivityResult(
@@ -657,102 +702,4 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
657 return@newInstance getString(R.string.user_data_import_success) 702 return@newInstance getString(R.string.user_data_import_success)
658 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) 703 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
659 } 704 }
660
661 /**
662 * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
663 */
664 val exportSaves = registerForActivityResult(
665 ActivityResultContracts.CreateDocument("application/zip")
666 ) { result ->
667 if (result == null) {
668 return@registerForActivityResult
669 }
670
671 IndeterminateProgressDialogFragment.newInstance(
672 this,
673 R.string.save_files_exporting,
674 false
675 ) {
676 val zipResult = FileUtil.zipFromInternalStorage(
677 File(savesFolderRoot),
678 savesFolderRoot,
679 BufferedOutputStream(contentResolver.openOutputStream(result))
680 )
681 return@newInstance when (zipResult) {
682 TaskState.Completed -> getString(R.string.export_success)
683 TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
684 }
685 }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
686 }
687
688 private val startForResultExportSave =
689 registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
690 File(getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
691 }
692
693 val importSaves =
694 registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
695 if (result == null) {
696 return@registerForActivityResult
697 }
698
699 NativeLibrary.initializeEmptyUserDirectory()
700
701 val inputZip = contentResolver.openInputStream(result)
702 // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
703 var validZip = false
704 val savesFolder = File(savesFolderRoot)
705 val cacheSaveDir = File("${applicationContext.cacheDir.path}/saves/")
706 cacheSaveDir.mkdir()
707
708 if (inputZip == null) {
709 Toast.makeText(
710 applicationContext,
711 getString(R.string.fatal_error),
712 Toast.LENGTH_LONG
713 ).show()
714 return@registerForActivityResult
715 }
716
717 val filterTitleId =
718 FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
719
720 try {
721 CoroutineScope(Dispatchers.IO).launch {
722 FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
723 cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
724 File(savesFolder, savePath).deleteRecursively()
725 File(cacheSaveDir, savePath).copyRecursively(
726 File(savesFolder, savePath),
727 true
728 )
729 validZip = true
730 }
731
732 withContext(Dispatchers.Main) {
733 if (!validZip) {
734 MessageDialogFragment.newInstance(
735 this@MainActivity,
736 titleId = R.string.save_file_invalid_zip_structure,
737 descriptionId = R.string.save_file_invalid_zip_structure_description
738 ).show(supportFragmentManager, MessageDialogFragment.TAG)
739 return@withContext
740 }
741 Toast.makeText(
742 applicationContext,
743 getString(R.string.save_file_imported_success),
744 Toast.LENGTH_LONG
745 ).show()
746 }
747
748 cacheSaveDir.deleteRecursively()
749 }
750 } catch (e: Exception) {
751 Toast.makeText(
752 applicationContext,
753 getString(R.string.fatal_error),
754 Toast.LENGTH_LONG
755 ).show()
756 }
757 }
758} 705}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
new file mode 100644
index 000000000..8cc5ea71f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
@@ -0,0 +1,8 @@
1// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4package org.yuzu.yuzu_emu.utils
5
6object AddonUtil {
7 val validAddonDirectories = listOf("cheats", "exefs", "romfs")
8}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
index bbe7bfa92..00c6bf90e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -22,6 +22,7 @@ import java.io.BufferedOutputStream
22import java.lang.NullPointerException 22import java.lang.NullPointerException
23import java.nio.charset.StandardCharsets 23import java.nio.charset.StandardCharsets
24import java.util.zip.ZipOutputStream 24import java.util.zip.ZipOutputStream
25import kotlin.IllegalStateException
25 26
26object FileUtil { 27object FileUtil {
27 const val PATH_TREE = "tree" 28 const val PATH_TREE = "tree"
@@ -342,6 +343,37 @@ object FileUtil {
342 return TaskState.Completed 343 return TaskState.Completed
343 } 344 }
344 345
346 /**
347 * Helper function that copies the contents of a DocumentFile folder into a [File]
348 * @param file [File] representation of the folder to copy into
349 * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa
350 */
351 fun DocumentFile.copyFilesTo(file: File) {
352 file.mkdirs()
353 if (!this.isDirectory || !file.isDirectory) {
354 throw IllegalStateException(
355 "[FileUtil] Tried to copy a folder into a file or vice versa"
356 )
357 }
358
359 this.listFiles().forEach {
360 val newFile = File(file, it.name!!)
361 if (it.isDirectory) {
362 newFile.mkdirs()
363 DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile)
364 } else {
365 val inputStream =
366 YuzuApplication.appContext.contentResolver.openInputStream(it.uri)
367 BufferedInputStream(inputStream).use { bos ->
368 if (!newFile.exists()) {
369 newFile.createNewFile()
370 }
371 newFile.outputStream().use { os -> bos.copyTo(os) }
372 }
373 }
374 }
375 }
376
345 fun isRootTreeUri(uri: Uri): Boolean { 377 fun isRootTreeUri(uri: Uri): Boolean {
346 val paths = uri.pathSegments 378 val paths = uri.pathSegments
347 return paths.size == 2 && PATH_TREE == paths[0] 379 return paths.size == 2 && PATH_TREE == paths[0]
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
index 4c7316ba3..7d629b7d5 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
@@ -105,4 +105,23 @@ object NativeConfig {
105 */ 105 */
106 @Synchronized 106 @Synchronized
107 external fun addGameDir(dir: GameDir) 107 external fun addGameDir(dir: GameDir)
108
109 /**
110 * Gets an array of the addons that are disabled for a given game
111 *
112 * @param programId String representation of a game's program ID
113 * @return An array of disabled addons
114 */
115 @Synchronized
116 external fun getDisabledAddons(programId: String): Array<String>
117
118 /**
119 * Clears the disabled addons array corresponding to [programId] and replaces them
120 * with [disabledAddons]
121 *
122 * @param programId String representation of a game's program ID
123 * @param disabledAddons Replacement array of disabled addons
124 */
125 @Synchronized
126 external fun setDisabledAddons(programId: String, disabledAddons: Array<String>)
108} 127}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index a56ed5662..df8935178 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -20,6 +20,12 @@ static jmethodID s_disk_cache_load_progress;
20static jmethodID s_on_emulation_started; 20static jmethodID s_on_emulation_started;
21static jmethodID s_on_emulation_stopped; 21static jmethodID s_on_emulation_stopped;
22 22
23static jclass s_string_class;
24static jclass s_pair_class;
25static jmethodID s_pair_constructor;
26static jfieldID s_pair_first_field;
27static jfieldID s_pair_second_field;
28
23static constexpr jint JNI_VERSION = JNI_VERSION_1_6; 29static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
24 30
25namespace IDCache { 31namespace IDCache {
@@ -79,6 +85,26 @@ jmethodID GetOnEmulationStopped() {
79 return s_on_emulation_stopped; 85 return s_on_emulation_stopped;
80} 86}
81 87
88jclass GetStringClass() {
89 return s_string_class;
90}
91
92jclass GetPairClass() {
93 return s_pair_class;
94}
95
96jmethodID GetPairConstructor() {
97 return s_pair_constructor;
98}
99
100jfieldID GetPairFirstField() {
101 return s_pair_first_field;
102}
103
104jfieldID GetPairSecondField() {
105 return s_pair_second_field;
106}
107
82} // namespace IDCache 108} // namespace IDCache
83 109
84#ifdef __cplusplus 110#ifdef __cplusplus
@@ -115,6 +141,18 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
115 s_on_emulation_stopped = 141 s_on_emulation_stopped =
116 env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V"); 142 env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V");
117 143
144 const jclass string_class = env->FindClass("java/lang/String");
145 s_string_class = reinterpret_cast<jclass>(env->NewGlobalRef(string_class));
146 env->DeleteLocalRef(string_class);
147
148 const jclass pair_class = env->FindClass("kotlin/Pair");
149 s_pair_class = reinterpret_cast<jclass>(env->NewGlobalRef(pair_class));
150 s_pair_constructor =
151 env->GetMethodID(pair_class, "<init>", "(Ljava/lang/Object;Ljava/lang/Object;)V");
152 s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;");
153 s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;");
154 env->DeleteLocalRef(pair_class);
155
118 // Initialize Android Storage 156 // Initialize Android Storage
119 Common::FS::Android::RegisterCallbacks(env, s_native_library_class); 157 Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
120 158
@@ -136,6 +174,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
136 env->DeleteGlobalRef(s_disk_cache_progress_class); 174 env->DeleteGlobalRef(s_disk_cache_progress_class);
137 env->DeleteGlobalRef(s_load_callback_stage_class); 175 env->DeleteGlobalRef(s_load_callback_stage_class);
138 env->DeleteGlobalRef(s_game_dir_class); 176 env->DeleteGlobalRef(s_game_dir_class);
177 env->DeleteGlobalRef(s_string_class);
178 env->DeleteGlobalRef(s_pair_class);
139 179
140 // UnInitialize applets 180 // UnInitialize applets
141 SoftwareKeyboard::CleanupJNI(env); 181 SoftwareKeyboard::CleanupJNI(env);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index 855649efa..36233423e 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -20,4 +20,10 @@ jmethodID GetDiskCacheLoadProgress();
20jmethodID GetOnEmulationStarted(); 20jmethodID GetOnEmulationStarted();
21jmethodID GetOnEmulationStopped(); 21jmethodID GetOnEmulationStopped();
22 22
23jclass GetStringClass();
24jclass GetPairClass();
25jmethodID GetPairConstructor();
26jfieldID GetPairFirstField();
27jfieldID GetPairSecondField();
28
23} // namespace IDCache 29} // namespace IDCache
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index e5d3158c8..ce570b811 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -14,6 +14,7 @@
14#include <android/api-level.h> 14#include <android/api-level.h>
15#include <android/native_window_jni.h> 15#include <android/native_window_jni.h>
16#include <common/fs/fs.h> 16#include <common/fs/fs.h>
17#include <core/file_sys/patch_manager.h>
17#include <core/file_sys/savedata_factory.h> 18#include <core/file_sys/savedata_factory.h>
18#include <core/loader/nro.h> 19#include <core/loader/nro.h>
19#include <jni.h> 20#include <jni.h>
@@ -79,6 +80,10 @@ Core::System& EmulationSession::System() {
79 return m_system; 80 return m_system;
80} 81}
81 82
83FileSys::ManualContentProvider* EmulationSession::ContentProvider() {
84 return m_manual_provider.get();
85}
86
82const EmuWindow_Android& EmulationSession::Window() const { 87const EmuWindow_Android& EmulationSession::Window() const {
83 return *m_window; 88 return *m_window;
84} 89}
@@ -455,6 +460,15 @@ void EmulationSession::OnEmulationStopped(Core::SystemResultStatus result) {
455 static_cast<jint>(result)); 460 static_cast<jint>(result));
456} 461}
457 462
463u64 EmulationSession::GetProgramId(JNIEnv* env, jstring jprogramId) {
464 auto program_id_string = GetJString(env, jprogramId);
465 try {
466 return std::stoull(program_id_string);
467 } catch (...) {
468 return 0;
469 }
470}
471
458static Core::SystemResultStatus RunEmulation(const std::string& filepath) { 472static Core::SystemResultStatus RunEmulation(const std::string& filepath) {
459 MicroProfileOnThreadCreate("EmuThread"); 473 MicroProfileOnThreadCreate("EmuThread");
460 SCOPE_EXIT({ MicroProfileShutdown(); }); 474 SCOPE_EXIT({ MicroProfileShutdown(); });
@@ -504,6 +518,27 @@ int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject
504 GetJString(env, j_file_extension)); 518 GetJString(env, j_file_extension));
505} 519}
506 520
521jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj,
522 jstring jprogramId,
523 jstring jupdatePath) {
524 u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
525 std::string updatePath = GetJString(env, jupdatePath);
526 std::shared_ptr<FileSys::NSP> nsp = std::make_shared<FileSys::NSP>(
527 EmulationSession::GetInstance().System().GetFilesystem()->OpenFile(updatePath,
528 FileSys::Mode::Read));
529 for (const auto& item : nsp->GetNCAs()) {
530 for (const auto& nca_details : item.second) {
531 if (nca_details.second->GetName().ends_with(".cnmt.nca")) {
532 auto update_id = nca_details.second->GetTitleId() & ~0xFFFULL;
533 if (update_id == program_id) {
534 return true;
535 }
536 }
537 }
538 }
539 return false;
540}
541
507void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz, 542void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(JNIEnv* env, jclass clazz,
508 jstring hook_lib_dir, 543 jstring hook_lib_dir,
509 jstring custom_driver_dir, 544 jstring custom_driver_dir,
@@ -665,13 +700,6 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass
665 EmulationSession::GetInstance().InitializeSystem(reload); 700 EmulationSession::GetInstance().InitializeSystem(reload);
666} 701}
667 702
668jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore(JNIEnv* env, jclass clazz) {
669 return {};
670}
671
672void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z(
673 JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate) {}
674
675jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats(JNIEnv* env, jclass clazz) { 703jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats(JNIEnv* env, jclass clazz) {
676 jdoubleArray j_stats = env->NewDoubleArray(4); 704 jdoubleArray j_stats = env->NewDoubleArray(4);
677 705
@@ -696,9 +724,13 @@ jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCpuBackend(JNIEnv* env, jclass
696 return ToJString(env, "JIT"); 724 return ToJString(env, "JIT");
697} 725}
698 726
699void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory(JNIEnv* env, 727void Java_org_yuzu_yuzu_1emu_NativeLibrary_applySettings(JNIEnv* env, jobject jobj) {
700 jclass clazz, 728 EmulationSession::GetInstance().System().ApplySettings();
701 jstring j_path) {} 729}
730
731void Java_org_yuzu_yuzu_1emu_NativeLibrary_logSettings(JNIEnv* env, jobject jobj) {
732 Settings::LogSettings();
733}
702 734
703void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, jclass clazz, 735void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, jclass clazz,
704 jstring j_path) { 736 jstring j_path) {
@@ -792,4 +824,60 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env,
792 return true; 824 return true;
793} 825}
794 826
827jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, jobject jobj,
828 jstring jpath,
829 jstring jprogramId) {
830 const auto path = GetJString(env, jpath);
831 const auto vFile =
832 Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path);
833 if (vFile == nullptr) {
834 return nullptr;
835 }
836
837 auto& system = EmulationSession::GetInstance().System();
838 auto program_id = EmulationSession::GetProgramId(env, jprogramId);
839 const FileSys::PatchManager pm{program_id, system.GetFileSystemController(),
840 system.GetContentProvider()};
841 const auto loader = Loader::GetLoader(system, vFile);
842
843 FileSys::VirtualFile update_raw;
844 loader->ReadUpdateRaw(update_raw);
845
846 auto addons = pm.GetPatchVersionNames(update_raw);
847 auto jemptyString = ToJString(env, "");
848 auto jemptyStringPair = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
849 jemptyString, jemptyString);
850 jobjectArray jaddonsArray =
851 env->NewObjectArray(addons.size(), IDCache::GetPairClass(), jemptyStringPair);
852 int i = 0;
853 for (const auto& addon : addons) {
854 jobject jaddon = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
855 ToJString(env, addon.first), ToJString(env, addon.second));
856 env->SetObjectArrayElement(jaddonsArray, i, jaddon);
857 ++i;
858 }
859 return jaddonsArray;
860}
861
862jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj,
863 jstring jprogramId) {
864 auto program_id = EmulationSession::GetProgramId(env, jprogramId);
865
866 auto& system = EmulationSession::GetInstance().System();
867
868 Service::Account::ProfileManager manager;
869 // TODO: Pass in a selected user once we get the relevant UI working
870 const auto user_id = manager.GetUser(static_cast<std::size_t>(0));
871 ASSERT(user_id);
872
873 const auto nandDir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir);
874 auto vfsNandDir = system.GetFilesystem()->OpenDirectory(Common::FS::PathToUTF8String(nandDir),
875 FileSys::Mode::Read);
876
877 const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath(
878 system, vfsNandDir, FileSys::SaveDataSpaceId::NandUser, FileSys::SaveDataType::SaveData,
879 program_id, user_id->AsU128(), 0);
880 return ToJString(env, user_save_data_path);
881}
882
795} // extern "C" 883} // extern "C"
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
index f1457bd1f..96c22d52b 100644
--- a/src/android/app/src/main/jni/native.h
+++ b/src/android/app/src/main/jni/native.h
@@ -54,6 +54,8 @@ public:
54 54
55 static void OnEmulationStarted(); 55 static void OnEmulationStarted();
56 56
57 static u64 GetProgramId(JNIEnv* env, jstring jprogramId);
58
57private: 59private:
58 static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max); 60 static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max);
59 static void OnEmulationStopped(Core::SystemResultStatus result); 61 static void OnEmulationStopped(Core::SystemResultStatus result);
diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp
index 9439d11e1..7f2485720 100644
--- a/src/android/app/src/main/jni/native_config.cpp
+++ b/src/android/app/src/main/jni/native_config.cpp
@@ -283,4 +283,30 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject
283 AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)}); 283 AndroidSettings::GameDir{uriString, static_cast<bool>(jdeepScanBoolean)});
284} 284}
285 285
286jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj,
287 jstring jprogramId) {
288 auto program_id = EmulationSession::GetProgramId(env, jprogramId);
289 auto& disabledAddons = Settings::values.disabled_addons[program_id];
290 jobjectArray jdisabledAddonsArray =
291 env->NewObjectArray(disabledAddons.size(), IDCache::GetStringClass(), ToJString(env, ""));
292 for (size_t i = 0; i < disabledAddons.size(); ++i) {
293 env->SetObjectArrayElement(jdisabledAddonsArray, i, ToJString(env, disabledAddons[i]));
294 }
295 return jdisabledAddonsArray;
296}
297
298void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj,
299 jstring jprogramId,
300 jobjectArray jdisabledAddons) {
301 auto program_id = EmulationSession::GetProgramId(env, jprogramId);
302 Settings::values.disabled_addons[program_id].clear();
303 std::vector<std::string> disabled_addons;
304 const int size = env->GetArrayLength(jdisabledAddons);
305 for (int i = 0; i < size; ++i) {
306 auto jaddon = static_cast<jstring>(env->GetObjectArrayElement(jdisabledAddons, i));
307 disabled_addons.push_back(GetJString(env, jaddon));
308 }
309 Settings::values.disabled_addons[program_id] = disabled_addons;
310}
311
286} // extern "C" 312} // extern "C"
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
new file mode 100644
index 000000000..0b9633855
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
@@ -0,0 +1,99 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.constraintlayout.widget.ConstraintLayout
3 xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:app="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 android:background="?attr/colorSurface">
9
10 <androidx.core.widget.NestedScrollView
11 android:id="@+id/list_all"
12 android:layout_width="0dp"
13 android:layout_height="match_parent"
14 android:clipToPadding="false"
15 android:fadeScrollbars="false"
16 android:scrollbars="vertical"
17 app:layout_constraintEnd_toEndOf="parent"
18 app:layout_constraintStart_toEndOf="@+id/icon_layout"
19 app:layout_constraintTop_toTopOf="parent">
20
21 <LinearLayout
22 android:id="@+id/layout_all"
23 android:layout_width="match_parent"
24 android:layout_height="wrap_content"
25 android:gravity="center_horizontal"
26 android:orientation="horizontal">
27
28 <androidx.recyclerview.widget.RecyclerView
29 android:id="@+id/list_properties"
30 android:layout_width="match_parent"
31 android:layout_height="match_parent"
32 tools:listitem="@layout/card_simple_outlined" />
33
34 </LinearLayout>
35
36 </androidx.core.widget.NestedScrollView>
37
38 <LinearLayout
39 android:id="@+id/icon_layout"
40 android:layout_width="wrap_content"
41 android:layout_height="wrap_content"
42 android:orientation="vertical"
43 app:layout_constraintStart_toStartOf="parent"
44 app:layout_constraintTop_toTopOf="parent">
45
46 <Button
47 android:id="@+id/button_back"
48 style="?attr/materialIconButtonStyle"
49 android:layout_width="wrap_content"
50 android:layout_height="wrap_content"
51 android:layout_gravity="start"
52 android:layout_margin="8dp"
53 app:icon="@drawable/ic_back"
54 app:iconSize="24dp"
55 app:iconTint="?attr/colorOnSurface" />
56
57 <com.google.android.material.card.MaterialCardView
58 style="?attr/materialCardViewElevatedStyle"
59 android:layout_width="wrap_content"
60 android:layout_height="wrap_content"
61 android:layout_marginHorizontal="16dp"
62 android:layout_marginTop="8dp"
63 app:cardCornerRadius="4dp"
64 app:cardElevation="4dp">
65
66 <ImageView
67 android:id="@+id/image_game_screen"
68 android:layout_width="175dp"
69 android:layout_height="175dp"
70 tools:src="@drawable/default_icon" />
71
72 </com.google.android.material.card.MaterialCardView>
73
74 <com.google.android.material.textview.MaterialTextView
75 android:id="@+id/title"
76 style="@style/TextAppearance.Material3.TitleMedium"
77 android:layout_width="match_parent"
78 android:layout_height="wrap_content"
79 android:layout_marginHorizontal="16dp"
80 android:layout_marginTop="12dp"
81 android:ellipsize="none"
82 android:marqueeRepeatLimit="marquee_forever"
83 android:requiresFadingEdge="horizontal"
84 android:singleLine="true"
85 android:textAlignment="center"
86 tools:text="deko_basic" />
87
88 </LinearLayout>
89
90 <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
91 android:id="@+id/button_start"
92 android:layout_width="wrap_content"
93 android:layout_height="wrap_content"
94 android:text="@string/start"
95 app:icon="@drawable/ic_play"
96 app:layout_constraintBottom_toBottomOf="parent"
97 app:layout_constraintEnd_toEndOf="parent" />
98
99</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/card_installable.xml b/src/android/app/src/main/res/layout/card_installable.xml
index f5b0e3741..ce2402d7a 100644
--- a/src/android/app/src/main/res/layout/card_installable.xml
+++ b/src/android/app/src/main/res/layout/card_installable.xml
@@ -11,7 +11,8 @@
11 <LinearLayout 11 <LinearLayout
12 android:layout_width="match_parent" 12 android:layout_width="match_parent"
13 android:layout_height="wrap_content" 13 android:layout_height="wrap_content"
14 android:layout_margin="16dp" 14 android:paddingVertical="16dp"
15 android:paddingHorizontal="24dp"
15 android:orientation="horizontal" 16 android:orientation="horizontal"
16 android:layout_gravity="center"> 17 android:layout_gravity="center">
17 18
diff --git a/src/android/app/src/main/res/layout/card_applet_option.xml b/src/android/app/src/main/res/layout/card_simple_outlined.xml
index 19fbec9f1..b73930e7e 100644
--- a/src/android/app/src/main/res/layout/card_applet_option.xml
+++ b/src/android/app/src/main/res/layout/card_simple_outlined.xml
@@ -16,7 +16,8 @@
16 android:layout_height="wrap_content" 16 android:layout_height="wrap_content"
17 android:orientation="horizontal" 17 android:orientation="horizontal"
18 android:layout_gravity="center" 18 android:layout_gravity="center"
19 android:padding="24dp"> 19 android:paddingVertical="16dp"
20 android:paddingHorizontal="24dp">
20 21
21 <ImageView 22 <ImageView
22 android:id="@+id/icon" 23 android:id="@+id/icon"
@@ -50,6 +51,23 @@
50 android:textAlignment="viewStart" 51 android:textAlignment="viewStart"
51 tools:text="@string/applets_description" /> 52 tools:text="@string/applets_description" />
52 53
54 <com.google.android.material.textview.MaterialTextView
55 style="@style/TextAppearance.Material3.LabelMedium"
56 android:id="@+id/details"
57 android:layout_width="match_parent"
58 android:layout_height="wrap_content"
59 android:textAlignment="viewStart"
60 android:textSize="14sp"
61 android:textStyle="bold"
62 android:singleLine="true"
63 android:marqueeRepeatLimit="marquee_forever"
64 android:ellipsize="none"
65 android:requiresFadingEdge="horizontal"
66 android:layout_marginTop="6dp"
67 android:visibility="gone"
68 tools:visibility="visible"
69 tools:text="/tree/primary:Games" />
70
53 </LinearLayout> 71 </LinearLayout>
54 72
55 </LinearLayout> 73 </LinearLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_addons.xml b/src/android/app/src/main/res/layout/fragment_addons.xml
new file mode 100644
index 000000000..a25e82766
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_addons.xml
@@ -0,0 +1,47 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 android:id="@+id/coordinator_about"
5 android:layout_width="match_parent"
6 android:layout_height="match_parent"
7 android:background="?attr/colorSurface">
8
9 <com.google.android.material.appbar.AppBarLayout
10 android:id="@+id/appbar_addons"
11 android:layout_width="match_parent"
12 android:layout_height="wrap_content"
13 android:fitsSystemWindows="true"
14 app:layout_constraintEnd_toEndOf="parent"
15 app:layout_constraintStart_toStartOf="parent"
16 app:layout_constraintTop_toTopOf="parent">
17
18 <com.google.android.material.appbar.MaterialToolbar
19 android:id="@+id/toolbar_addons"
20 android:layout_width="match_parent"
21 android:layout_height="?attr/actionBarSize"
22 app:navigationIcon="@drawable/ic_back" />
23
24 </com.google.android.material.appbar.AppBarLayout>
25
26 <androidx.recyclerview.widget.RecyclerView
27 android:id="@+id/list_addons"
28 android:layout_width="match_parent"
29 android:layout_height="0dp"
30 android:clipToPadding="false"
31 app:layout_behavior="@string/appbar_scrolling_view_behavior"
32 app:layout_constraintBottom_toBottomOf="parent"
33 app:layout_constraintEnd_toEndOf="parent"
34 app:layout_constraintStart_toStartOf="parent"
35 app:layout_constraintTop_toBottomOf="@+id/appbar_addons" />
36
37 <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
38 android:id="@+id/button_install"
39 android:layout_width="wrap_content"
40 android:layout_height="wrap_content"
41 android:layout_gravity="bottom|end"
42 android:text="@string/install"
43 app:icon="@drawable/ic_add"
44 app:layout_constraintBottom_toBottomOf="parent"
45 app:layout_constraintEnd_toEndOf="parent" />
46
47</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_game_info.xml b/src/android/app/src/main/res/layout/fragment_game_info.xml
new file mode 100644
index 000000000..80ede8a8c
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_game_info.xml
@@ -0,0 +1,125 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 xmlns:tools="http://schemas.android.com/tools"
5 android:id="@+id/coordinator_about"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 android:background="?attr/colorSurface">
9
10 <com.google.android.material.appbar.AppBarLayout
11 android:id="@+id/appbar_info"
12 android:layout_width="match_parent"
13 android:layout_height="wrap_content"
14 android:fitsSystemWindows="true">
15
16 <com.google.android.material.appbar.MaterialToolbar
17 android:id="@+id/toolbar_info"
18 android:layout_width="match_parent"
19 android:layout_height="?attr/actionBarSize"
20 app:navigationIcon="@drawable/ic_back" />
21
22 </com.google.android.material.appbar.AppBarLayout>
23
24 <androidx.core.widget.NestedScrollView
25 android:id="@+id/scroll_info"
26 android:layout_width="match_parent"
27 android:layout_height="wrap_content"
28 app:layout_behavior="@string/appbar_scrolling_view_behavior">
29
30 <LinearLayout
31 android:id="@+id/content_info"
32 android:layout_width="match_parent"
33 android:layout_height="wrap_content"
34 android:orientation="vertical"
35 android:paddingHorizontal="16dp">
36
37 <com.google.android.material.textfield.TextInputLayout
38 android:id="@+id/path"
39 android:layout_width="match_parent"
40 android:layout_height="wrap_content"
41 android:paddingTop="16dp">
42
43 <com.google.android.material.textfield.TextInputEditText
44 android:id="@+id/path_field"
45 android:layout_width="match_parent"
46 android:layout_height="wrap_content"
47 android:editable="false"
48 android:importantForAutofill="no"
49 android:inputType="none"
50 android:minHeight="48dp"
51 android:textAlignment="viewStart"
52 tools:text="1.0.0" />
53
54 </com.google.android.material.textfield.TextInputLayout>
55
56 <com.google.android.material.textfield.TextInputLayout
57 android:id="@+id/program_id"
58 android:layout_width="match_parent"
59 android:layout_height="wrap_content"
60 android:paddingTop="16dp">
61
62 <com.google.android.material.textfield.TextInputEditText
63 android:id="@+id/program_id_field"
64 android:layout_width="match_parent"
65 android:layout_height="wrap_content"
66 android:editable="false"
67 android:importantForAutofill="no"
68 android:inputType="none"
69 android:minHeight="48dp"
70 android:textAlignment="viewStart"
71 tools:text="1.0.0" />
72
73 </com.google.android.material.textfield.TextInputLayout>
74
75 <com.google.android.material.textfield.TextInputLayout
76 android:id="@+id/developer"
77 android:layout_width="match_parent"
78 android:layout_height="wrap_content"
79 android:paddingTop="16dp">
80
81 <com.google.android.material.textfield.TextInputEditText
82 android:id="@+id/developer_field"
83 android:layout_width="match_parent"
84 android:layout_height="wrap_content"
85 android:editable="false"
86 android:importantForAutofill="no"
87 android:inputType="none"
88 android:minHeight="48dp"
89 android:textAlignment="viewStart"
90 tools:text="1.0.0" />
91
92 </com.google.android.material.textfield.TextInputLayout>
93
94 <com.google.android.material.textfield.TextInputLayout
95 android:id="@+id/version"
96 android:layout_width="match_parent"
97 android:layout_height="wrap_content"
98 android:paddingTop="16dp">
99
100 <com.google.android.material.textfield.TextInputEditText
101 android:id="@+id/version_field"
102 android:layout_width="match_parent"
103 android:layout_height="wrap_content"
104 android:editable="false"
105 android:importantForAutofill="no"
106 android:inputType="none"
107 android:minHeight="48dp"
108 android:textAlignment="viewStart"
109 tools:text="1.0.0" />
110
111 </com.google.android.material.textfield.TextInputLayout>
112
113 <com.google.android.material.button.MaterialButton
114 android:id="@+id/button_copy"
115 style="@style/Widget.Material3.Button"
116 android:layout_width="wrap_content"
117 android:layout_height="wrap_content"
118 android:layout_marginTop="16dp"
119 android:text="@string/copy_details" />
120
121 </LinearLayout>
122
123 </androidx.core.widget.NestedScrollView>
124
125</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_game_properties.xml b/src/android/app/src/main/res/layout/fragment_game_properties.xml
new file mode 100644
index 000000000..72ecbde30
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_game_properties.xml
@@ -0,0 +1,86 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.constraintlayout.widget.ConstraintLayout
3 xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:app="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 android:background="?attr/colorSurface">
9
10 <androidx.core.widget.NestedScrollView
11 android:id="@+id/list_all"
12 android:layout_width="match_parent"
13 android:layout_height="match_parent"
14 android:scrollbars="vertical"
15 android:fadeScrollbars="false"
16 android:clipToPadding="false">
17
18 <LinearLayout
19 android:id="@+id/layout_all"
20 android:layout_width="match_parent"
21 android:layout_height="wrap_content"
22 android:orientation="vertical"
23 android:gravity="center_horizontal">
24
25 <Button
26 android:id="@+id/button_back"
27 style="?attr/materialIconButtonStyle"
28 android:layout_width="wrap_content"
29 android:layout_height="wrap_content"
30 android:layout_margin="8dp"
31 android:layout_gravity="start"
32 app:icon="@drawable/ic_back"
33 app:iconSize="24dp"
34 app:iconTint="?attr/colorOnSurface" />
35
36 <com.google.android.material.card.MaterialCardView
37 style="?attr/materialCardViewElevatedStyle"
38 android:layout_width="wrap_content"
39 android:layout_height="wrap_content"
40 android:layout_marginTop="8dp"
41 app:cardCornerRadius="4dp"
42 app:cardElevation="4dp">
43
44 <ImageView
45 android:id="@+id/image_game_screen"
46 android:layout_width="175dp"
47 android:layout_height="175dp"
48 tools:src="@drawable/default_icon"/>
49
50 </com.google.android.material.card.MaterialCardView>
51
52 <com.google.android.material.textview.MaterialTextView
53 android:id="@+id/title"
54 style="@style/TextAppearance.Material3.TitleMedium"
55 android:layout_width="wrap_content"
56 android:layout_height="wrap_content"
57 android:layout_marginTop="12dp"
58 android:layout_marginBottom="12dp"
59 android:layout_marginHorizontal="16dp"
60 android:ellipsize="none"
61 android:marqueeRepeatLimit="marquee_forever"
62 android:requiresFadingEdge="horizontal"
63 android:singleLine="true"
64 android:textAlignment="center"
65 tools:text="deko_basic" />
66
67 <androidx.recyclerview.widget.RecyclerView
68 android:id="@+id/list_properties"
69 android:layout_width="match_parent"
70 android:layout_height="match_parent"
71 tools:listitem="@layout/card_simple_outlined" />
72
73 </LinearLayout>
74
75 </androidx.core.widget.NestedScrollView>
76
77 <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
78 android:id="@+id/button_start"
79 android:layout_width="wrap_content"
80 android:layout_height="wrap_content"
81 android:text="@string/start"
82 app:icon="@drawable/ic_play"
83 app:layout_constraintBottom_toBottomOf="parent"
84 app:layout_constraintEnd_toEndOf="parent" />
85
86</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/list_item_addon.xml b/src/android/app/src/main/res/layout/list_item_addon.xml
new file mode 100644
index 000000000..74ca04ef1
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_addon.xml
@@ -0,0 +1,57 @@
1<?xml version="1.0" encoding="utf-8"?>
2<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 xmlns:app="http://schemas.android.com/apk/res-auto"
4 xmlns:tools="http://schemas.android.com/tools"
5 android:id="@+id/addon_container"
6 android:layout_width="match_parent"
7 android:layout_height="wrap_content"
8 android:background="?attr/selectableItemBackground"
9 android:focusable="true"
10 android:paddingHorizontal="20dp"
11 android:paddingVertical="16dp">
12
13 <LinearLayout
14 android:id="@+id/text_container"
15 android:layout_width="0dp"
16 android:layout_height="wrap_content"
17 android:layout_marginEnd="16dp"
18 android:orientation="vertical"
19 app:layout_constraintBottom_toBottomOf="@+id/addon_switch"
20 app:layout_constraintEnd_toStartOf="@+id/addon_switch"
21 app:layout_constraintStart_toStartOf="parent"
22 app:layout_constraintTop_toTopOf="@+id/addon_switch">
23
24 <com.google.android.material.textview.MaterialTextView
25 android:id="@+id/title"
26 style="@style/TextAppearance.Material3.HeadlineMedium"
27 android:layout_width="wrap_content"
28 android:layout_height="wrap_content"
29 android:textAlignment="viewStart"
30 android:textSize="17sp"
31 app:lineHeight="28dp"
32 tools:text="1440p Resolution" />
33
34 <com.google.android.material.textview.MaterialTextView
35 android:id="@+id/version"
36 style="@style/TextAppearance.Material3.BodySmall"
37 android:layout_width="wrap_content"
38 android:layout_height="wrap_content"
39 android:layout_marginTop="@dimen/spacing_small"
40 android:textAlignment="viewStart"
41 tools:text="1.0.0" />
42
43 </LinearLayout>
44
45 <com.google.android.material.materialswitch.MaterialSwitch
46 android:id="@+id/addon_switch"
47 android:layout_width="wrap_content"
48 android:layout_height="wrap_content"
49 android:focusable="true"
50 android:gravity="center"
51 android:nextFocusLeft="@id/addon_container"
52 app:layout_constraintBottom_toBottomOf="parent"
53 app:layout_constraintEnd_toEndOf="parent"
54 app:layout_constraintStart_toEndOf="@id/text_container"
55 app:layout_constraintTop_toTopOf="parent" />
56
57</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
index cf70b4bc4..1c69bf0db 100644
--- a/src/android/app/src/main/res/navigation/home_navigation.xml
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -124,5 +124,38 @@
124 android:id="@+id/gameFoldersFragment" 124 android:id="@+id/gameFoldersFragment"
125 android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment" 125 android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment"
126 android:label="GameFoldersFragment" /> 126 android:label="GameFoldersFragment" />
127 <fragment
128 android:id="@+id/perGamePropertiesFragment"
129 android:name="org.yuzu.yuzu_emu.fragments.GamePropertiesFragment"
130 android:label="PerGamePropertiesFragment" >
131 <argument
132 android:name="game"
133 app:argType="org.yuzu.yuzu_emu.model.Game" />
134 <action
135 android:id="@+id/action_perGamePropertiesFragment_to_gameInfoFragment"
136 app:destination="@id/gameInfoFragment" />
137 <action
138 android:id="@+id/action_perGamePropertiesFragment_to_addonsFragment"
139 app:destination="@id/addonsFragment" />
140 </fragment>
141 <action
142 android:id="@+id/action_global_perGamePropertiesFragment"
143 app:destination="@id/perGamePropertiesFragment" />
144 <fragment
145 android:id="@+id/gameInfoFragment"
146 android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment"
147 android:label="GameInfoFragment" >
148 <argument
149 android:name="game"
150 app:argType="org.yuzu.yuzu_emu.model.Game" />
151 </fragment>
152 <fragment
153 android:id="@+id/addonsFragment"
154 android:name="org.yuzu.yuzu_emu.fragments.AddonsFragment"
155 android:label="AddonsFragment" >
156 <argument
157 android:name="game"
158 app:argType="org.yuzu.yuzu_emu.model.Game" />
159 </fragment>
127 160
128</navigation> 161</navigation>
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index 380d14213..992b5ae44 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -13,7 +13,7 @@
13 <dimen name="menu_width">256dp</dimen> 13 <dimen name="menu_width">256dp</dimen>
14 <dimen name="card_width">165dp</dimen> 14 <dimen name="card_width">165dp</dimen>
15 <dimen name="icon_inset">24dp</dimen> 15 <dimen name="icon_inset">24dp</dimen>
16 <dimen name="spacing_bottom_list_fab">76dp</dimen> 16 <dimen name="spacing_bottom_list_fab">96dp</dimen>
17 <dimen name="spacing_fab">24dp</dimen> 17 <dimen name="spacing_fab">24dp</dimen>
18 18
19 <dimen name="dialog_margin">20dp</dimen> 19 <dimen name="dialog_margin">20dp</dimen>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index a6ccef8a1..cd5571aa9 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -91,7 +91,10 @@
91 <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string> 91 <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
92 <string name="manage_save_data">Manage save data</string> 92 <string name="manage_save_data">Manage save data</string>
93 <string name="manage_save_data_description">Save data found. Please select an option below.</string> 93 <string name="manage_save_data_description">Save data found. Please select an option below.</string>
94 <string name="import_save_warning">Import save data</string>
95 <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>
94 <string name="import_export_saves_description">Import or export save files</string> 96 <string name="import_export_saves_description">Import or export save files</string>
97 <string name="save_files_importing">Importing save files…</string>
95 <string name="save_files_exporting">Exporting save files…</string> 98 <string name="save_files_exporting">Exporting save files…</string>
96 <string name="save_file_imported_success">Imported successfully</string> 99 <string name="save_file_imported_success">Imported successfully</string>
97 <string name="save_file_invalid_zip_structure">Invalid save directory structure</string> 100 <string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
@@ -266,6 +269,11 @@
266 <string name="delete">Delete</string> 269 <string name="delete">Delete</string>
267 <string name="edit">Edit</string> 270 <string name="edit">Edit</string>
268 <string name="export_success">Exported successfully</string> 271 <string name="export_success">Exported successfully</string>
272 <string name="start">Start</string>
273 <string name="clear">Clear</string>
274 <string name="global">Global</string>
275 <string name="custom">Custom</string>
276 <string name="notice">Notice</string>
269 277
270 <!-- GPU driver installation --> 278 <!-- GPU driver installation -->
271 <string name="select_gpu_driver">Select GPU driver</string> 279 <string name="select_gpu_driver">Select GPU driver</string>
@@ -291,6 +299,43 @@
291 <string name="preferences_debug">Debug</string> 299 <string name="preferences_debug">Debug</string>
292 <string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string> 300 <string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
293 301
302 <!-- Game properties -->
303 <string name="info">Info</string>
304 <string name="info_description">Program ID, developer, version</string>
305 <string name="per_game_settings">Per-game settings</string>
306 <string name="per_game_settings_description">Edit settings specific to this game</string>
307 <string name="launch_options">Launch config</string>
308 <string name="path">Path</string>
309 <string name="program_id">Program ID</string>
310 <string name="developer">Developer</string>
311 <string name="version">Version</string>
312 <string name="copy_details">Copy details</string>
313 <string name="add_ons">Add-ons</string>
314 <string name="add_ons_description">Toggle mods, updates and DLC</string>
315 <string name="clear_shader_cache">Clear shader cache</string>
316 <string name="clear_shader_cache_description">Removes all shaders built while playing this game</string>
317 <string name="cleared_shaders_successfully">Cleared shaders successfully</string>
318 <string name="addons_game">Addons: %1$s</string>
319 <string name="save_data">Save data</string>
320 <string name="save_data_description">Manage save data specific to this game</string>
321 <string name="delete_save_data">Delete save data</string>
322 <string name="delete_save_data_description">Removes all save data specific to this game</string>
323 <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>
324 <string name="save_data_deleted_successfully">Save data deleted successfully</string>
325 <string name="select_content_type">Content type</string>
326 <string name="updates_and_dlc">Updates and DLC</string>
327 <string name="mods_and_cheats">Mods and cheats</string>
328 <string name="addon_notice">Important addon notice</string>
329 <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
330 <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>
331 <string name="invalid_directory">Invalid directory</string>
332 <!-- "cheats/" "romfs/" and "exefs/ should not be translated -->
333 <string name="invalid_directory_description">Please make sure that the directory you selected contains a cheats/, romfs/, or exefs/ folder and try again.</string>
334 <string name="addon_installed_successfully">Addon installed successfully</string>
335 <string name="verifying_content">Verifying content…</string>
336 <string name="content_install_notice">Content install notice</string>
337 <string name="content_install_notice_description">The content that you selected does not match this game.\nInstall anyway?</string>
338
294 <!-- ROM loading errors --> 339 <!-- ROM loading errors -->
295 <string name="loader_error_encrypted">Your ROM is encrypted</string> 340 <string name="loader_error_encrypted">Your ROM is encrypted</string>
296 <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> 341 <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>