Browse Source
android: Add Game properties
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 cachence_cpp
40 changed files with 2227 additions and 253 deletions
-
35src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
-
52src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
-
6src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
-
16src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
-
133src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
-
214src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
-
68src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt
-
148src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt
-
418src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
-
2src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
-
61src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt
-
37src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
-
10src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
-
83src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
-
42src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
-
34src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
-
15src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt
-
2src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
-
11src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
-
327src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
-
8src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt
-
32src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
-
19src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt
-
40src/android/app/src/main/jni/id_cache.cpp
-
6src/android/app/src/main/jni/id_cache.h
-
108src/android/app/src/main/jni/native.cpp
-
2src/android/app/src/main/jni/native.h
-
26src/android/app/src/main/jni/native_config.cpp
-
99src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
-
3src/android/app/src/main/res/layout/card_installable.xml
-
20src/android/app/src/main/res/layout/card_simple_outlined.xml
-
47src/android/app/src/main/res/layout/fragment_addons.xml
-
125src/android/app/src/main/res/layout/fragment_game_info.xml
-
86src/android/app/src/main/res/layout/fragment_game_properties.xml
-
57src/android/app/src/main/res/layout/list_item_addon.xml
-
33src/android/app/src/main/res/navigation/home_navigation.xml
-
2src/android/app/src/main/res/values/dimens.xml
-
45src/android/app/src/main/res/values/strings.xml
@ -0,0 +1,52 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.adapters |
|||
|
|||
import android.view.LayoutInflater |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.AsyncDifferConfig |
|||
import androidx.recyclerview.widget.DiffUtil |
|||
import androidx.recyclerview.widget.ListAdapter |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding |
|||
import org.yuzu.yuzu_emu.model.Addon |
|||
|
|||
class AddonAdapter : ListAdapter<Addon, AddonAdapter.AddonViewHolder>( |
|||
AsyncDifferConfig.Builder(DiffCallback()).build() |
|||
) { |
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { |
|||
ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) |
|||
.also { return AddonViewHolder(it) } |
|||
} |
|||
|
|||
override fun getItemCount(): Int = currentList.size |
|||
|
|||
override fun onBindViewHolder(holder: AddonViewHolder, position: Int) = |
|||
holder.bind(currentList[position]) |
|||
|
|||
inner class AddonViewHolder(val binding: ListItemAddonBinding) : |
|||
RecyclerView.ViewHolder(binding.root) { |
|||
fun bind(addon: Addon) { |
|||
binding.root.setOnClickListener { |
|||
binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked |
|||
} |
|||
binding.title.text = addon.title |
|||
binding.version.text = addon.version |
|||
binding.addonSwitch.setOnCheckedChangeListener { _, checked -> |
|||
addon.enabled = checked |
|||
} |
|||
binding.addonSwitch.isChecked = addon.enabled |
|||
} |
|||
} |
|||
|
|||
private class DiffCallback : DiffUtil.ItemCallback<Addon>() { |
|||
override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean { |
|||
return oldItem == newItem |
|||
} |
|||
|
|||
override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean { |
|||
return oldItem == newItem |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.adapters |
|||
|
|||
import android.text.TextUtils |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.core.content.res.ResourcesCompat |
|||
import androidx.lifecycle.Lifecycle |
|||
import androidx.lifecycle.LifecycleOwner |
|||
import androidx.lifecycle.lifecycleScope |
|||
import androidx.lifecycle.repeatOnLifecycle |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import kotlinx.coroutines.launch |
|||
import org.yuzu.yuzu_emu.databinding.CardInstallableBinding |
|||
import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding |
|||
import org.yuzu.yuzu_emu.model.GameProperty |
|||
import org.yuzu.yuzu_emu.model.InstallableProperty |
|||
import org.yuzu.yuzu_emu.model.SubmenuProperty |
|||
|
|||
class GamePropertiesAdapter( |
|||
private val viewLifecycle: LifecycleOwner, |
|||
private var properties: List<GameProperty> |
|||
) : |
|||
RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() { |
|||
override fun onCreateViewHolder( |
|||
parent: ViewGroup, |
|||
viewType: Int |
|||
): GamePropertyViewHolder { |
|||
val inflater = LayoutInflater.from(parent.context) |
|||
return when (viewType) { |
|||
PropertyType.Submenu.ordinal -> { |
|||
SubmenuPropertyViewHolder( |
|||
CardSimpleOutlinedBinding.inflate( |
|||
inflater, |
|||
parent, |
|||
false |
|||
) |
|||
) |
|||
} |
|||
|
|||
else -> InstallablePropertyViewHolder( |
|||
CardInstallableBinding.inflate( |
|||
inflater, |
|||
parent, |
|||
false |
|||
) |
|||
) |
|||
} |
|||
} |
|||
|
|||
override fun getItemCount(): Int = properties.size |
|||
|
|||
override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) = |
|||
holder.bind(properties[position]) |
|||
|
|||
override fun getItemViewType(position: Int): Int { |
|||
return when (properties[position]) { |
|||
is SubmenuProperty -> PropertyType.Submenu.ordinal |
|||
else -> PropertyType.Installable.ordinal |
|||
} |
|||
} |
|||
|
|||
sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { |
|||
abstract fun bind(property: GameProperty) |
|||
} |
|||
|
|||
inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) : |
|||
GamePropertyViewHolder(binding.root) { |
|||
override fun bind(property: GameProperty) { |
|||
val submenuProperty = property as SubmenuProperty |
|||
|
|||
binding.root.setOnClickListener { |
|||
submenuProperty.action.invoke() |
|||
} |
|||
|
|||
binding.title.setText(submenuProperty.titleId) |
|||
binding.description.setText(submenuProperty.descriptionId) |
|||
binding.icon.setImageDrawable( |
|||
ResourcesCompat.getDrawable( |
|||
binding.icon.context.resources, |
|||
submenuProperty.iconId, |
|||
binding.icon.context.theme |
|||
) |
|||
) |
|||
|
|||
binding.details.postDelayed({ |
|||
binding.details.isSelected = true |
|||
binding.details.ellipsize = TextUtils.TruncateAt.MARQUEE |
|||
}, 3000) |
|||
|
|||
if (submenuProperty.details != null) { |
|||
binding.details.visibility = View.VISIBLE |
|||
binding.details.text = submenuProperty.details.invoke() |
|||
} else if (submenuProperty.detailsFlow != null) { |
|||
binding.details.visibility = View.VISIBLE |
|||
viewLifecycle.lifecycleScope.launch { |
|||
viewLifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { |
|||
submenuProperty.detailsFlow.collect { binding.details.text = it } |
|||
} |
|||
} |
|||
} else { |
|||
binding.details.visibility = View.GONE |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class InstallablePropertyViewHolder(val binding: CardInstallableBinding) : |
|||
GamePropertyViewHolder(binding.root) { |
|||
override fun bind(property: GameProperty) { |
|||
val installableProperty = property as InstallableProperty |
|||
|
|||
binding.title.setText(installableProperty.titleId) |
|||
binding.description.setText(installableProperty.descriptionId) |
|||
|
|||
if (installableProperty.install != null) { |
|||
binding.buttonInstall.visibility = View.VISIBLE |
|||
binding.buttonInstall.setOnClickListener { installableProperty.install.invoke() } |
|||
} |
|||
if (installableProperty.export != null) { |
|||
binding.buttonExport.visibility = View.VISIBLE |
|||
binding.buttonExport.setOnClickListener { installableProperty.export.invoke() } |
|||
} |
|||
} |
|||
} |
|||
|
|||
enum class PropertyType { |
|||
Submenu, |
|||
Installable |
|||
} |
|||
} |
|||
@ -0,0 +1,214 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.annotation.SuppressLint |
|||
import android.content.Intent |
|||
import android.os.Bundle |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.activity.result.contract.ActivityResultContracts |
|||
import androidx.core.view.ViewCompat |
|||
import androidx.core.view.WindowInsetsCompat |
|||
import androidx.core.view.updatePadding |
|||
import androidx.documentfile.provider.DocumentFile |
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.lifecycle.Lifecycle |
|||
import androidx.lifecycle.lifecycleScope |
|||
import androidx.lifecycle.repeatOnLifecycle |
|||
import androidx.navigation.findNavController |
|||
import androidx.navigation.fragment.navArgs |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import com.google.android.material.transition.MaterialSharedAxis |
|||
import kotlinx.coroutines.launch |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.adapters.AddonAdapter |
|||
import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding |
|||
import org.yuzu.yuzu_emu.model.AddonViewModel |
|||
import org.yuzu.yuzu_emu.model.HomeViewModel |
|||
import org.yuzu.yuzu_emu.utils.AddonUtil |
|||
import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo |
|||
import java.io.File |
|||
|
|||
class AddonsFragment : Fragment() { |
|||
private var _binding: FragmentAddonsBinding? = null |
|||
private val binding get() = _binding!! |
|||
|
|||
private val homeViewModel: HomeViewModel by activityViewModels() |
|||
private val addonViewModel: AddonViewModel by activityViewModels() |
|||
|
|||
private val args by navArgs<AddonsFragmentArgs>() |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
addonViewModel.onOpenAddons(args.game) |
|||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) |
|||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
} |
|||
|
|||
override fun onCreateView( |
|||
inflater: LayoutInflater, |
|||
container: ViewGroup?, |
|||
savedInstanceState: Bundle? |
|||
): View { |
|||
_binding = FragmentAddonsBinding.inflate(inflater) |
|||
return binding.root |
|||
} |
|||
|
|||
// This is using the correct scope, lint is just acting up |
|||
@SuppressLint("UnsafeRepeatOnLifecycleDetector") |
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
|||
super.onViewCreated(view, savedInstanceState) |
|||
homeViewModel.setNavigationVisibility(visible = false, animated = false) |
|||
homeViewModel.setStatusBarShadeVisibility(false) |
|||
|
|||
binding.toolbarAddons.setNavigationOnClickListener { |
|||
binding.root.findNavController().popBackStack() |
|||
} |
|||
|
|||
binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) |
|||
|
|||
binding.listAddons.apply { |
|||
layoutManager = LinearLayoutManager(requireContext()) |
|||
adapter = AddonAdapter() |
|||
} |
|||
|
|||
viewLifecycleOwner.lifecycleScope.apply { |
|||
launch { |
|||
repeatOnLifecycle(Lifecycle.State.STARTED) { |
|||
addonViewModel.addonList.collect { |
|||
(binding.listAddons.adapter as AddonAdapter).submitList(it) |
|||
} |
|||
} |
|||
} |
|||
launch { |
|||
repeatOnLifecycle(Lifecycle.State.STARTED) { |
|||
addonViewModel.showModInstallPicker.collect { |
|||
if (it) { |
|||
installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) |
|||
addonViewModel.showModInstallPicker(false) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
launch { |
|||
repeatOnLifecycle(Lifecycle.State.STARTED) { |
|||
addonViewModel.showModNoticeDialog.collect { |
|||
if (it) { |
|||
MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.addon_notice, |
|||
descriptionId = R.string.addon_notice_description, |
|||
positiveAction = { addonViewModel.showModInstallPicker(true) } |
|||
).show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
addonViewModel.showModNoticeDialog(false) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
binding.buttonInstall.setOnClickListener { |
|||
ContentTypeSelectionDialogFragment().show( |
|||
parentFragmentManager, |
|||
ContentTypeSelectionDialogFragment.TAG |
|||
) |
|||
} |
|||
|
|||
setInsets() |
|||
} |
|||
|
|||
override fun onResume() { |
|||
super.onResume() |
|||
addonViewModel.refreshAddons() |
|||
} |
|||
|
|||
override fun onDestroy() { |
|||
super.onDestroy() |
|||
addonViewModel.onCloseAddons() |
|||
} |
|||
|
|||
val installAddon = |
|||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> |
|||
if (result == null) { |
|||
return@registerForActivityResult |
|||
} |
|||
|
|||
val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) |
|||
if (externalAddonDirectory == null) { |
|||
MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.invalid_directory, |
|||
descriptionId = R.string.invalid_directory_description |
|||
).show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
return@registerForActivityResult |
|||
} |
|||
|
|||
val isValid = externalAddonDirectory.listFiles() |
|||
.any { AddonUtil.validAddonDirectories.contains(it.name) } |
|||
val errorMessage = MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.invalid_directory, |
|||
descriptionId = R.string.invalid_directory_description |
|||
) |
|||
if (isValid) { |
|||
IndeterminateProgressDialogFragment.newInstance( |
|||
requireActivity(), |
|||
R.string.installing_game_content, |
|||
false |
|||
) { |
|||
val parentDirectoryName = externalAddonDirectory.name |
|||
val internalAddonDirectory = |
|||
File(args.game.addonDir + parentDirectoryName) |
|||
try { |
|||
externalAddonDirectory.copyFilesTo(internalAddonDirectory) |
|||
} catch (_: Exception) { |
|||
return@newInstance errorMessage |
|||
} |
|||
addonViewModel.refreshAddons() |
|||
return@newInstance getString(R.string.addon_installed_successfully) |
|||
}.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) |
|||
} else { |
|||
errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
} |
|||
} |
|||
|
|||
private fun setInsets() = |
|||
ViewCompat.setOnApplyWindowInsetsListener( |
|||
binding.root |
|||
) { _: View, windowInsets: WindowInsetsCompat -> |
|||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) |
|||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) |
|||
|
|||
val leftInsets = barInsets.left + cutoutInsets.left |
|||
val rightInsets = barInsets.right + cutoutInsets.right |
|||
|
|||
val mlpToolbar = binding.toolbarAddons.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpToolbar.leftMargin = leftInsets |
|||
mlpToolbar.rightMargin = rightInsets |
|||
binding.toolbarAddons.layoutParams = mlpToolbar |
|||
|
|||
val mlpAddonsList = binding.listAddons.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpAddonsList.leftMargin = leftInsets |
|||
mlpAddonsList.rightMargin = rightInsets |
|||
binding.listAddons.layoutParams = mlpAddonsList |
|||
binding.listAddons.updatePadding( |
|||
bottom = barInsets.bottom + |
|||
resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) |
|||
) |
|||
|
|||
val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) |
|||
val mlpFab = |
|||
binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpFab.leftMargin = leftInsets + fabSpacing |
|||
mlpFab.rightMargin = rightInsets + fabSpacing |
|||
mlpFab.bottomMargin = barInsets.bottom + fabSpacing |
|||
binding.buttonInstall.layoutParams = mlpFab |
|||
|
|||
windowInsets |
|||
} |
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.app.Dialog |
|||
import android.content.DialogInterface |
|||
import android.os.Bundle |
|||
import androidx.fragment.app.DialogFragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.preference.PreferenceManager |
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.YuzuApplication |
|||
import org.yuzu.yuzu_emu.model.AddonViewModel |
|||
import org.yuzu.yuzu_emu.ui.main.MainActivity |
|||
|
|||
class ContentTypeSelectionDialogFragment : DialogFragment() { |
|||
private val addonViewModel: AddonViewModel by activityViewModels() |
|||
|
|||
private val preferences get() = |
|||
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) |
|||
|
|||
private var selectedItem = 0 |
|||
|
|||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { |
|||
val launchOptions = |
|||
arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) |
|||
|
|||
if (savedInstanceState != null) { |
|||
selectedItem = savedInstanceState.getInt(SELECTED_ITEM) |
|||
} |
|||
|
|||
val mainActivity = requireActivity() as MainActivity |
|||
return MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.select_content_type) |
|||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> |
|||
when (selectedItem) { |
|||
0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) |
|||
else -> { |
|||
if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { |
|||
preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() |
|||
addonViewModel.showModNoticeDialog(true) |
|||
return@setPositiveButton |
|||
} |
|||
addonViewModel.showModInstallPicker(true) |
|||
} |
|||
} |
|||
} |
|||
.setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> |
|||
selectedItem = i |
|||
} |
|||
.setNegativeButton(android.R.string.cancel, null) |
|||
.show() |
|||
} |
|||
|
|||
override fun onSaveInstanceState(outState: Bundle) { |
|||
super.onSaveInstanceState(outState) |
|||
outState.putInt(SELECTED_ITEM, selectedItem) |
|||
} |
|||
|
|||
companion object { |
|||
const val TAG = "ContentTypeSelectionDialogFragment" |
|||
|
|||
private const val SELECTED_ITEM = "SelectedItem" |
|||
private const val MOD_NOTICE_SEEN = "ModNoticeSeen" |
|||
} |
|||
} |
|||
@ -0,0 +1,148 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.content.ClipData |
|||
import android.content.ClipboardManager |
|||
import android.content.Context |
|||
import android.net.Uri |
|||
import android.os.Build |
|||
import android.os.Bundle |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import android.widget.Toast |
|||
import androidx.core.view.ViewCompat |
|||
import androidx.core.view.WindowInsetsCompat |
|||
import androidx.core.view.updatePadding |
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.navigation.findNavController |
|||
import androidx.navigation.fragment.navArgs |
|||
import com.google.android.material.transition.MaterialSharedAxis |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding |
|||
import org.yuzu.yuzu_emu.model.HomeViewModel |
|||
import org.yuzu.yuzu_emu.utils.GameMetadata |
|||
|
|||
class GameInfoFragment : Fragment() { |
|||
private var _binding: FragmentGameInfoBinding? = null |
|||
private val binding get() = _binding!! |
|||
|
|||
private val homeViewModel: HomeViewModel by activityViewModels() |
|||
|
|||
private val args by navArgs<GameInfoFragmentArgs>() |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) |
|||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
|
|||
// Check for an up-to-date version string |
|||
args.game.version = GameMetadata.getVersion(args.game.path, true) |
|||
} |
|||
|
|||
override fun onCreateView( |
|||
inflater: LayoutInflater, |
|||
container: ViewGroup?, |
|||
savedInstanceState: Bundle? |
|||
): View { |
|||
_binding = FragmentGameInfoBinding.inflate(inflater) |
|||
return binding.root |
|||
} |
|||
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
|||
super.onViewCreated(view, savedInstanceState) |
|||
homeViewModel.setNavigationVisibility(visible = false, animated = false) |
|||
homeViewModel.setStatusBarShadeVisibility(false) |
|||
|
|||
binding.apply { |
|||
toolbarInfo.title = args.game.title |
|||
toolbarInfo.setNavigationOnClickListener { |
|||
view.findNavController().popBackStack() |
|||
} |
|||
|
|||
val pathString = Uri.parse(args.game.path).path ?: "" |
|||
path.setHint(R.string.path) |
|||
pathField.setText(pathString) |
|||
pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } |
|||
|
|||
programId.setHint(R.string.program_id) |
|||
programIdField.setText(args.game.programIdHex) |
|||
programIdField.setOnClickListener { |
|||
copyToClipboard(getString(R.string.program_id), args.game.programIdHex) |
|||
} |
|||
|
|||
if (args.game.developer.isNotEmpty()) { |
|||
developer.setHint(R.string.developer) |
|||
developerField.setText(args.game.developer) |
|||
developerField.setOnClickListener { |
|||
copyToClipboard(getString(R.string.developer), args.game.developer) |
|||
} |
|||
} else { |
|||
developer.visibility = View.GONE |
|||
} |
|||
|
|||
version.setHint(R.string.version) |
|||
versionField.setText(args.game.version) |
|||
versionField.setOnClickListener { |
|||
copyToClipboard(getString(R.string.version), args.game.version) |
|||
} |
|||
|
|||
buttonCopy.setOnClickListener { |
|||
val details = """ |
|||
${args.game.title} |
|||
${getString(R.string.path)} - $pathString |
|||
${getString(R.string.program_id)} - ${args.game.programIdHex} |
|||
${getString(R.string.developer)} - ${args.game.developer} |
|||
${getString(R.string.version)} - ${args.game.version} |
|||
""".trimIndent() |
|||
copyToClipboard(args.game.title, details) |
|||
} |
|||
} |
|||
|
|||
setInsets() |
|||
} |
|||
|
|||
private fun copyToClipboard(label: String, body: String) { |
|||
val clipBoard = |
|||
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager |
|||
val clip = ClipData.newPlainText(label, body) |
|||
clipBoard.setPrimaryClip(clip) |
|||
|
|||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { |
|||
Toast.makeText( |
|||
requireContext(), |
|||
R.string.copied_to_clipboard, |
|||
Toast.LENGTH_SHORT |
|||
).show() |
|||
} |
|||
} |
|||
|
|||
private fun setInsets() = |
|||
ViewCompat.setOnApplyWindowInsetsListener( |
|||
binding.root |
|||
) { _: View, windowInsets: WindowInsetsCompat -> |
|||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) |
|||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) |
|||
|
|||
val leftInsets = barInsets.left + cutoutInsets.left |
|||
val rightInsets = barInsets.right + cutoutInsets.right |
|||
|
|||
val mlpToolbar = binding.toolbarInfo.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpToolbar.leftMargin = leftInsets |
|||
mlpToolbar.rightMargin = rightInsets |
|||
binding.toolbarInfo.layoutParams = mlpToolbar |
|||
|
|||
val mlpScrollAbout = binding.scrollInfo.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpScrollAbout.leftMargin = leftInsets |
|||
mlpScrollAbout.rightMargin = rightInsets |
|||
binding.scrollInfo.layoutParams = mlpScrollAbout |
|||
|
|||
binding.contentInfo.updatePadding(bottom = barInsets.bottom) |
|||
|
|||
windowInsets |
|||
} |
|||
} |
|||
@ -0,0 +1,418 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.os.Bundle |
|||
import android.text.TextUtils |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import android.widget.Toast |
|||
import androidx.activity.result.contract.ActivityResultContracts |
|||
import androidx.core.view.ViewCompat |
|||
import androidx.core.view.WindowInsetsCompat |
|||
import androidx.core.view.updatePadding |
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.lifecycle.Lifecycle |
|||
import androidx.lifecycle.lifecycleScope |
|||
import androidx.lifecycle.repeatOnLifecycle |
|||
import androidx.navigation.findNavController |
|||
import androidx.navigation.fragment.navArgs |
|||
import androidx.recyclerview.widget.GridLayoutManager |
|||
import com.google.android.material.transition.MaterialSharedAxis |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.launch |
|||
import kotlinx.coroutines.withContext |
|||
import org.yuzu.yuzu_emu.HomeNavigationDirections |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.YuzuApplication |
|||
import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter |
|||
import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding |
|||
import org.yuzu.yuzu_emu.features.settings.model.Settings |
|||
import org.yuzu.yuzu_emu.model.DriverViewModel |
|||
import org.yuzu.yuzu_emu.model.GameProperty |
|||
import org.yuzu.yuzu_emu.model.GamesViewModel |
|||
import org.yuzu.yuzu_emu.model.HomeViewModel |
|||
import org.yuzu.yuzu_emu.model.InstallableProperty |
|||
import org.yuzu.yuzu_emu.model.SubmenuProperty |
|||
import org.yuzu.yuzu_emu.model.TaskState |
|||
import org.yuzu.yuzu_emu.utils.DirectoryInitialization |
|||
import org.yuzu.yuzu_emu.utils.FileUtil |
|||
import org.yuzu.yuzu_emu.utils.GameIconUtils |
|||
import org.yuzu.yuzu_emu.utils.GpuDriverHelper |
|||
import org.yuzu.yuzu_emu.utils.MemoryUtil |
|||
import java.io.BufferedInputStream |
|||
import java.io.BufferedOutputStream |
|||
import java.io.File |
|||
|
|||
class GamePropertiesFragment : Fragment() { |
|||
private var _binding: FragmentGamePropertiesBinding? = null |
|||
private val binding get() = _binding!! |
|||
|
|||
private val homeViewModel: HomeViewModel by activityViewModels() |
|||
private val gamesViewModel: GamesViewModel by activityViewModels() |
|||
private val driverViewModel: DriverViewModel by activityViewModels() |
|||
|
|||
private val args by navArgs<GamePropertiesFragmentArgs>() |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true) |
|||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false) |
|||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
} |
|||
|
|||
override fun onCreateView( |
|||
inflater: LayoutInflater, |
|||
container: ViewGroup?, |
|||
savedInstanceState: Bundle? |
|||
): View { |
|||
_binding = FragmentGamePropertiesBinding.inflate(layoutInflater) |
|||
return binding.root |
|||
} |
|||
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
|||
super.onViewCreated(view, savedInstanceState) |
|||
homeViewModel.setNavigationVisibility(visible = false, animated = true) |
|||
homeViewModel.setStatusBarShadeVisibility(true) |
|||
|
|||
binding.buttonBack.setOnClickListener { |
|||
view.findNavController().popBackStack() |
|||
} |
|||
|
|||
GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen) |
|||
binding.title.text = args.game.title |
|||
binding.title.postDelayed( |
|||
{ |
|||
binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE |
|||
binding.title.isSelected = true |
|||
}, |
|||
3000 |
|||
) |
|||
|
|||
binding.buttonStart.setOnClickListener { |
|||
LaunchGameDialogFragment.newInstance(args.game) |
|||
.show(childFragmentManager, LaunchGameDialogFragment.TAG) |
|||
} |
|||
|
|||
reloadList() |
|||
|
|||
viewLifecycleOwner.lifecycleScope.launch { |
|||
repeatOnLifecycle(Lifecycle.State.STARTED) { |
|||
homeViewModel.openImportSaves.collect { |
|||
if (it) { |
|||
importSaves.launch(arrayOf("application/zip")) |
|||
homeViewModel.setOpenImportSaves(false) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
setInsets() |
|||
} |
|||
|
|||
override fun onDestroy() { |
|||
super.onDestroy() |
|||
gamesViewModel.reloadGames(true) |
|||
} |
|||
|
|||
private fun reloadList() { |
|||
_binding ?: return |
|||
|
|||
driverViewModel.updateDriverNameForGame(args.game) |
|||
val properties = mutableListOf<GameProperty>().apply { |
|||
add( |
|||
SubmenuProperty( |
|||
R.string.info, |
|||
R.string.info_description, |
|||
R.drawable.ic_info_outline |
|||
) { |
|||
val action = GamePropertiesFragmentDirections |
|||
.actionPerGamePropertiesFragmentToGameInfoFragment(args.game) |
|||
binding.root.findNavController().navigate(action) |
|||
} |
|||
) |
|||
add( |
|||
SubmenuProperty( |
|||
R.string.preferences_settings, |
|||
R.string.per_game_settings_description, |
|||
R.drawable.ic_settings |
|||
) { |
|||
val action = HomeNavigationDirections.actionGlobalSettingsActivity( |
|||
args.game, |
|||
Settings.MenuTag.SECTION_ROOT |
|||
) |
|||
binding.root.findNavController().navigate(action) |
|||
} |
|||
) |
|||
|
|||
if (!args.game.isHomebrew) { |
|||
add( |
|||
SubmenuProperty( |
|||
R.string.add_ons, |
|||
R.string.add_ons_description, |
|||
R.drawable.ic_edit |
|||
) { |
|||
val action = GamePropertiesFragmentDirections |
|||
.actionPerGamePropertiesFragmentToAddonsFragment(args.game) |
|||
binding.root.findNavController().navigate(action) |
|||
} |
|||
) |
|||
add( |
|||
InstallableProperty( |
|||
R.string.save_data, |
|||
R.string.save_data_description, |
|||
{ |
|||
MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.import_save_warning, |
|||
descriptionId = R.string.import_save_warning_description, |
|||
positiveAction = { homeViewModel.setOpenImportSaves(true) } |
|||
).show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
}, |
|||
if (File(args.game.saveDir).exists()) { |
|||
{ exportSaves.launch(args.game.saveZipName) } |
|||
} else { |
|||
null |
|||
} |
|||
) |
|||
) |
|||
|
|||
val saveDirFile = File(args.game.saveDir) |
|||
if (saveDirFile.exists()) { |
|||
add( |
|||
SubmenuProperty( |
|||
R.string.delete_save_data, |
|||
R.string.delete_save_data_description, |
|||
R.drawable.ic_delete, |
|||
action = { |
|||
MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.delete_save_data, |
|||
descriptionId = R.string.delete_save_data_warning_description, |
|||
positiveAction = { |
|||
File(args.game.saveDir).deleteRecursively() |
|||
Toast.makeText( |
|||
YuzuApplication.appContext, |
|||
R.string.save_data_deleted_successfully, |
|||
Toast.LENGTH_SHORT |
|||
).show() |
|||
reloadList() |
|||
} |
|||
).show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
} |
|||
) |
|||
) |
|||
} |
|||
|
|||
val shaderCacheDir = File( |
|||
DirectoryInitialization.userDirectory + |
|||
"/shader/" + args.game.settingsName.lowercase() |
|||
) |
|||
if (shaderCacheDir.exists()) { |
|||
add( |
|||
SubmenuProperty( |
|||
R.string.clear_shader_cache, |
|||
R.string.clear_shader_cache_description, |
|||
R.drawable.ic_delete, |
|||
{ |
|||
if (shaderCacheDir.exists()) { |
|||
val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } |
|||
.map { it.length() }.sum() |
|||
MemoryUtil.bytesToSizeUnit(bytes.toFloat()) |
|||
} else { |
|||
MemoryUtil.bytesToSizeUnit(0f) |
|||
} |
|||
} |
|||
) { |
|||
shaderCacheDir.deleteRecursively() |
|||
Toast.makeText( |
|||
YuzuApplication.appContext, |
|||
R.string.cleared_shaders_successfully, |
|||
Toast.LENGTH_SHORT |
|||
).show() |
|||
reloadList() |
|||
} |
|||
) |
|||
} |
|||
} |
|||
} |
|||
binding.listProperties.apply { |
|||
layoutManager = |
|||
GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) |
|||
adapter = GamePropertiesAdapter(viewLifecycleOwner, properties) |
|||
} |
|||
} |
|||
|
|||
override fun onResume() { |
|||
super.onResume() |
|||
driverViewModel.updateDriverNameForGame(args.game) |
|||
} |
|||
|
|||
private fun setInsets() = |
|||
ViewCompat.setOnApplyWindowInsetsListener( |
|||
binding.root |
|||
) { _: View, windowInsets: WindowInsetsCompat -> |
|||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) |
|||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) |
|||
|
|||
val leftInsets = barInsets.left + cutoutInsets.left |
|||
val rightInsets = barInsets.right + cutoutInsets.right |
|||
|
|||
val smallLayout = resources.getBoolean(R.bool.small_layout) |
|||
if (smallLayout) { |
|||
val mlpListAll = |
|||
binding.listAll.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpListAll.leftMargin = leftInsets |
|||
mlpListAll.rightMargin = rightInsets |
|||
binding.listAll.layoutParams = mlpListAll |
|||
} else { |
|||
if (ViewCompat.getLayoutDirection(binding.root) == |
|||
ViewCompat.LAYOUT_DIRECTION_LTR |
|||
) { |
|||
val mlpListAll = |
|||
binding.listAll.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpListAll.rightMargin = rightInsets |
|||
binding.listAll.layoutParams = mlpListAll |
|||
|
|||
val mlpIconLayout = |
|||
binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpIconLayout.topMargin = barInsets.top |
|||
mlpIconLayout.leftMargin = leftInsets |
|||
binding.iconLayout!!.layoutParams = mlpIconLayout |
|||
} else { |
|||
val mlpListAll = |
|||
binding.listAll.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpListAll.leftMargin = leftInsets |
|||
binding.listAll.layoutParams = mlpListAll |
|||
|
|||
val mlpIconLayout = |
|||
binding.iconLayout!!.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpIconLayout.topMargin = barInsets.top |
|||
mlpIconLayout.rightMargin = rightInsets |
|||
binding.iconLayout!!.layoutParams = mlpIconLayout |
|||
} |
|||
} |
|||
|
|||
val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) |
|||
val mlpFab = |
|||
binding.buttonStart.layoutParams as ViewGroup.MarginLayoutParams |
|||
mlpFab.leftMargin = leftInsets + fabSpacing |
|||
mlpFab.rightMargin = rightInsets + fabSpacing |
|||
mlpFab.bottomMargin = barInsets.bottom + fabSpacing |
|||
binding.buttonStart.layoutParams = mlpFab |
|||
|
|||
binding.layoutAll.updatePadding( |
|||
top = barInsets.top, |
|||
bottom = barInsets.bottom + |
|||
resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) |
|||
) |
|||
|
|||
windowInsets |
|||
} |
|||
|
|||
private val importSaves = |
|||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> |
|||
if (result == null) { |
|||
return@registerForActivityResult |
|||
} |
|||
|
|||
val inputZip = requireContext().contentResolver.openInputStream(result) |
|||
val savesFolder = File(args.game.saveDir) |
|||
val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") |
|||
cacheSaveDir.mkdir() |
|||
|
|||
if (inputZip == null) { |
|||
Toast.makeText( |
|||
YuzuApplication.appContext, |
|||
getString(R.string.fatal_error), |
|||
Toast.LENGTH_LONG |
|||
).show() |
|||
return@registerForActivityResult |
|||
} |
|||
|
|||
IndeterminateProgressDialogFragment.newInstance( |
|||
requireActivity(), |
|||
R.string.save_files_importing, |
|||
false |
|||
) { |
|||
try { |
|||
FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir) |
|||
val files = cacheSaveDir.listFiles() |
|||
var savesFolderFile: File? = null |
|||
if (files != null) { |
|||
val savesFolderName = args.game.programIdHex |
|||
for (file in files) { |
|||
if (file.isDirectory && file.name == savesFolderName) { |
|||
savesFolderFile = file |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (savesFolderFile != null) { |
|||
savesFolder.deleteRecursively() |
|||
savesFolder.mkdir() |
|||
savesFolderFile.copyRecursively(savesFolder) |
|||
savesFolderFile.deleteRecursively() |
|||
} |
|||
|
|||
withContext(Dispatchers.Main) { |
|||
if (savesFolderFile == null) { |
|||
MessageDialogFragment.newInstance( |
|||
requireActivity(), |
|||
titleId = R.string.save_file_invalid_zip_structure, |
|||
descriptionId = R.string.save_file_invalid_zip_structure_description |
|||
).show(parentFragmentManager, MessageDialogFragment.TAG) |
|||
return@withContext |
|||
} |
|||
Toast.makeText( |
|||
YuzuApplication.appContext, |
|||
getString(R.string.save_file_imported_success), |
|||
Toast.LENGTH_LONG |
|||
).show() |
|||
reloadList() |
|||
} |
|||
|
|||
cacheSaveDir.deleteRecursively() |
|||
} catch (e: Exception) { |
|||
Toast.makeText( |
|||
YuzuApplication.appContext, |
|||
getString(R.string.fatal_error), |
|||
Toast.LENGTH_LONG |
|||
).show() |
|||
} |
|||
}.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) |
|||
} |
|||
|
|||
/** |
|||
* Exports the save file located in the given folder path by creating a zip file and opening a |
|||
* file picker to save. |
|||
*/ |
|||
private val exportSaves = registerForActivityResult( |
|||
ActivityResultContracts.CreateDocument("application/zip") |
|||
) { result -> |
|||
if (result == null) { |
|||
return@registerForActivityResult |
|||
} |
|||
|
|||
IndeterminateProgressDialogFragment.newInstance( |
|||
requireActivity(), |
|||
R.string.save_files_exporting, |
|||
false |
|||
) { |
|||
val saveLocation = args.game.saveDir |
|||
val zipResult = FileUtil.zipFromInternalStorage( |
|||
File(saveLocation), |
|||
saveLocation.replaceAfterLast("/", ""), |
|||
BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)) |
|||
) |
|||
return@newInstance when (zipResult) { |
|||
TaskState.Completed -> getString(R.string.export_success) |
|||
TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) |
|||
} |
|||
}.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG) |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.app.Dialog |
|||
import android.content.DialogInterface |
|||
import android.os.Bundle |
|||
import androidx.fragment.app.DialogFragment |
|||
import androidx.navigation.fragment.findNavController |
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder |
|||
import org.yuzu.yuzu_emu.HomeNavigationDirections |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.model.Game |
|||
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable |
|||
|
|||
class LaunchGameDialogFragment : DialogFragment() { |
|||
private var selectedItem = 0 |
|||
|
|||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { |
|||
val game = requireArguments().parcelable<Game>(GAME) |
|||
val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom)) |
|||
|
|||
if (savedInstanceState != null) { |
|||
selectedItem = savedInstanceState.getInt(SELECTED_ITEM) |
|||
} |
|||
|
|||
return MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.launch_options) |
|||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> |
|||
val action = HomeNavigationDirections |
|||
.actionGlobalEmulationActivity(game, selectedItem != 0) |
|||
requireParentFragment().findNavController().navigate(action) |
|||
} |
|||
.setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> |
|||
selectedItem = i |
|||
} |
|||
.setNegativeButton(android.R.string.cancel, null) |
|||
.show() |
|||
} |
|||
|
|||
override fun onSaveInstanceState(outState: Bundle) { |
|||
super.onSaveInstanceState(outState) |
|||
outState.putInt(SELECTED_ITEM, selectedItem) |
|||
} |
|||
|
|||
companion object { |
|||
const val TAG = "LaunchGameDialogFragment" |
|||
|
|||
const val GAME = "Game" |
|||
const val SELECTED_ITEM = "SelectedItem" |
|||
|
|||
fun newInstance(game: Game): LaunchGameDialogFragment { |
|||
val args = Bundle() |
|||
args.putParcelable(GAME, game) |
|||
val fragment = LaunchGameDialogFragment() |
|||
fragment.arguments = args |
|||
return fragment |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.model |
|||
|
|||
data class Addon( |
|||
var enabled: Boolean, |
|||
val title: String, |
|||
val version: String |
|||
) |
|||
@ -0,0 +1,83 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.model |
|||
|
|||
import androidx.lifecycle.ViewModel |
|||
import androidx.lifecycle.viewModelScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
import kotlinx.coroutines.launch |
|||
import kotlinx.coroutines.withContext |
|||
import org.yuzu.yuzu_emu.NativeLibrary |
|||
import org.yuzu.yuzu_emu.utils.NativeConfig |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
|
|||
class AddonViewModel : ViewModel() { |
|||
private val _addonList = MutableStateFlow(mutableListOf<Addon>()) |
|||
val addonList get() = _addonList.asStateFlow() |
|||
|
|||
private val _showModInstallPicker = MutableStateFlow(false) |
|||
val showModInstallPicker get() = _showModInstallPicker.asStateFlow() |
|||
|
|||
private val _showModNoticeDialog = MutableStateFlow(false) |
|||
val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() |
|||
|
|||
var game: Game? = null |
|||
|
|||
private val isRefreshing = AtomicBoolean(false) |
|||
|
|||
fun onOpenAddons(game: Game) { |
|||
this.game = game |
|||
refreshAddons() |
|||
} |
|||
|
|||
fun refreshAddons() { |
|||
if (isRefreshing.get() || game == null) { |
|||
return |
|||
} |
|||
isRefreshing.set(true) |
|||
viewModelScope.launch { |
|||
withContext(Dispatchers.IO) { |
|||
val addonList = mutableListOf<Addon>() |
|||
val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId) |
|||
NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach { |
|||
val name = it.first.replace("[D] ", "") |
|||
addonList.add(Addon(!disabledAddons.contains(name), name, it.second)) |
|||
} |
|||
addonList.sortBy { it.title } |
|||
_addonList.value = addonList |
|||
isRefreshing.set(false) |
|||
} |
|||
} |
|||
} |
|||
|
|||
fun onCloseAddons() { |
|||
if (_addonList.value.isEmpty()) { |
|||
return |
|||
} |
|||
|
|||
NativeConfig.setDisabledAddons( |
|||
game!!.programId, |
|||
_addonList.value.mapNotNull { |
|||
if (it.enabled) { |
|||
null |
|||
} else { |
|||
it.title |
|||
} |
|||
}.toTypedArray() |
|||
) |
|||
NativeConfig.saveGlobalConfig() |
|||
_addonList.value.clear() |
|||
game = null |
|||
} |
|||
|
|||
fun showModInstallPicker(install: Boolean) { |
|||
_showModInstallPicker.value = install |
|||
} |
|||
|
|||
fun showModNoticeDialog(show: Boolean) { |
|||
_showModNoticeDialog.value = show |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.model |
|||
|
|||
import androidx.annotation.DrawableRes |
|||
import androidx.annotation.StringRes |
|||
import kotlinx.coroutines.flow.StateFlow |
|||
|
|||
interface GameProperty { |
|||
@get:StringRes |
|||
val titleId: Int |
|||
get() = -1 |
|||
|
|||
@get:StringRes |
|||
val descriptionId: Int |
|||
get() = -1 |
|||
} |
|||
|
|||
data class SubmenuProperty( |
|||
override val titleId: Int, |
|||
override val descriptionId: Int, |
|||
@DrawableRes val iconId: Int, |
|||
val details: (() -> String)? = null, |
|||
val detailsFlow: StateFlow<String>? = null, |
|||
val action: () -> Unit |
|||
) : GameProperty |
|||
|
|||
data class InstallableProperty( |
|||
override val titleId: Int, |
|||
override val descriptionId: Int, |
|||
val install: (() -> Unit)? = null, |
|||
val export: (() -> Unit)? = null |
|||
) : GameProperty |
|||
@ -0,0 +1,8 @@ |
|||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project |
|||
// SPDX-License-Identifier: GPL-2.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.utils |
|||
|
|||
object AddonUtil { |
|||
val validAddonDirectories = listOf("cheats", "exefs", "romfs") |
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.constraintlayout.widget.ConstraintLayout |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface"> |
|||
|
|||
<androidx.core.widget.NestedScrollView |
|||
android:id="@+id/list_all" |
|||
android:layout_width="0dp" |
|||
android:layout_height="match_parent" |
|||
android:clipToPadding="false" |
|||
android:fadeScrollbars="false" |
|||
android:scrollbars="vertical" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toEndOf="@+id/icon_layout" |
|||
app:layout_constraintTop_toTopOf="parent"> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/layout_all" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:gravity="center_horizontal" |
|||
android:orientation="horizontal"> |
|||
|
|||
<androidx.recyclerview.widget.RecyclerView |
|||
android:id="@+id/list_properties" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:listitem="@layout/card_simple_outlined" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</androidx.core.widget.NestedScrollView> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/icon_layout" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent"> |
|||
|
|||
<Button |
|||
android:id="@+id/button_back" |
|||
style="?attr/materialIconButtonStyle" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="start" |
|||
android:layout_margin="8dp" |
|||
app:icon="@drawable/ic_back" |
|||
app:iconSize="24dp" |
|||
app:iconTint="?attr/colorOnSurface" /> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
style="?attr/materialCardViewElevatedStyle" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginHorizontal="16dp" |
|||
android:layout_marginTop="8dp" |
|||
app:cardCornerRadius="4dp" |
|||
app:cardElevation="4dp"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_game_screen" |
|||
android:layout_width="175dp" |
|||
android:layout_height="175dp" |
|||
tools:src="@drawable/default_icon" /> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
<com.google.android.material.textview.MaterialTextView |
|||
android:id="@+id/title" |
|||
style="@style/TextAppearance.Material3.TitleMedium" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginHorizontal="16dp" |
|||
android:layout_marginTop="12dp" |
|||
android:ellipsize="none" |
|||
android:marqueeRepeatLimit="marquee_forever" |
|||
android:requiresFadingEdge="horizontal" |
|||
android:singleLine="true" |
|||
android:textAlignment="center" |
|||
tools:text="deko_basic" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton |
|||
android:id="@+id/button_start" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/start" |
|||
app:icon="@drawable/ic_play" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
@ -0,0 +1,47 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
android:id="@+id/coordinator_about" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface"> |
|||
|
|||
<com.google.android.material.appbar.AppBarLayout |
|||
android:id="@+id/appbar_addons" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:fitsSystemWindows="true" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent"> |
|||
|
|||
<com.google.android.material.appbar.MaterialToolbar |
|||
android:id="@+id/toolbar_addons" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
app:navigationIcon="@drawable/ic_back" /> |
|||
|
|||
</com.google.android.material.appbar.AppBarLayout> |
|||
|
|||
<androidx.recyclerview.widget.RecyclerView |
|||
android:id="@+id/list_addons" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="0dp" |
|||
android:clipToPadding="false" |
|||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toBottomOf="@+id/appbar_addons" /> |
|||
|
|||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton |
|||
android:id="@+id/button_install" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="bottom|end" |
|||
android:text="@string/install" |
|||
app:icon="@drawable/ic_add" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
@ -0,0 +1,125 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:id="@+id/coordinator_about" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface"> |
|||
|
|||
<com.google.android.material.appbar.AppBarLayout |
|||
android:id="@+id/appbar_info" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:fitsSystemWindows="true"> |
|||
|
|||
<com.google.android.material.appbar.MaterialToolbar |
|||
android:id="@+id/toolbar_info" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
app:navigationIcon="@drawable/ic_back" /> |
|||
|
|||
</com.google.android.material.appbar.AppBarLayout> |
|||
|
|||
<androidx.core.widget.NestedScrollView |
|||
android:id="@+id/scroll_info" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
app:layout_behavior="@string/appbar_scrolling_view_behavior"> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/content_info" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:paddingHorizontal="16dp"> |
|||
|
|||
<com.google.android.material.textfield.TextInputLayout |
|||
android:id="@+id/path" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:paddingTop="16dp"> |
|||
|
|||
<com.google.android.material.textfield.TextInputEditText |
|||
android:id="@+id/path_field" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:editable="false" |
|||
android:importantForAutofill="no" |
|||
android:inputType="none" |
|||
android:minHeight="48dp" |
|||
android:textAlignment="viewStart" |
|||
tools:text="1.0.0" /> |
|||
|
|||
</com.google.android.material.textfield.TextInputLayout> |
|||
|
|||
<com.google.android.material.textfield.TextInputLayout |
|||
android:id="@+id/program_id" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:paddingTop="16dp"> |
|||
|
|||
<com.google.android.material.textfield.TextInputEditText |
|||
android:id="@+id/program_id_field" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:editable="false" |
|||
android:importantForAutofill="no" |
|||
android:inputType="none" |
|||
android:minHeight="48dp" |
|||
android:textAlignment="viewStart" |
|||
tools:text="1.0.0" /> |
|||
|
|||
</com.google.android.material.textfield.TextInputLayout> |
|||
|
|||
<com.google.android.material.textfield.TextInputLayout |
|||
android:id="@+id/developer" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:paddingTop="16dp"> |
|||
|
|||
<com.google.android.material.textfield.TextInputEditText |
|||
android:id="@+id/developer_field" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:editable="false" |
|||
android:importantForAutofill="no" |
|||
android:inputType="none" |
|||
android:minHeight="48dp" |
|||
android:textAlignment="viewStart" |
|||
tools:text="1.0.0" /> |
|||
|
|||
</com.google.android.material.textfield.TextInputLayout> |
|||
|
|||
<com.google.android.material.textfield.TextInputLayout |
|||
android:id="@+id/version" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:paddingTop="16dp"> |
|||
|
|||
<com.google.android.material.textfield.TextInputEditText |
|||
android:id="@+id/version_field" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:editable="false" |
|||
android:importantForAutofill="no" |
|||
android:inputType="none" |
|||
android:minHeight="48dp" |
|||
android:textAlignment="viewStart" |
|||
tools:text="1.0.0" /> |
|||
|
|||
</com.google.android.material.textfield.TextInputLayout> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_copy" |
|||
style="@style/Widget.Material3.Button" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="16dp" |
|||
android:text="@string/copy_details" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</androidx.core.widget.NestedScrollView> |
|||
|
|||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
|||
@ -0,0 +1,86 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.constraintlayout.widget.ConstraintLayout |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface"> |
|||
|
|||
<androidx.core.widget.NestedScrollView |
|||
android:id="@+id/list_all" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:scrollbars="vertical" |
|||
android:fadeScrollbars="false" |
|||
android:clipToPadding="false"> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/layout_all" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:gravity="center_horizontal"> |
|||
|
|||
<Button |
|||
android:id="@+id/button_back" |
|||
style="?attr/materialIconButtonStyle" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_margin="8dp" |
|||
android:layout_gravity="start" |
|||
app:icon="@drawable/ic_back" |
|||
app:iconSize="24dp" |
|||
app:iconTint="?attr/colorOnSurface" /> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
style="?attr/materialCardViewElevatedStyle" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="8dp" |
|||
app:cardCornerRadius="4dp" |
|||
app:cardElevation="4dp"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_game_screen" |
|||
android:layout_width="175dp" |
|||
android:layout_height="175dp" |
|||
tools:src="@drawable/default_icon"/> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
<com.google.android.material.textview.MaterialTextView |
|||
android:id="@+id/title" |
|||
style="@style/TextAppearance.Material3.TitleMedium" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="12dp" |
|||
android:layout_marginBottom="12dp" |
|||
android:layout_marginHorizontal="16dp" |
|||
android:ellipsize="none" |
|||
android:marqueeRepeatLimit="marquee_forever" |
|||
android:requiresFadingEdge="horizontal" |
|||
android:singleLine="true" |
|||
android:textAlignment="center" |
|||
tools:text="deko_basic" /> |
|||
|
|||
<androidx.recyclerview.widget.RecyclerView |
|||
android:id="@+id/list_properties" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:listitem="@layout/card_simple_outlined" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</androidx.core.widget.NestedScrollView> |
|||
|
|||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton |
|||
android:id="@+id/button_start" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/start" |
|||
app:icon="@drawable/ic_play" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
@ -0,0 +1,57 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:id="@+id/addon_container" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:background="?attr/selectableItemBackground" |
|||
android:focusable="true" |
|||
android:paddingHorizontal="20dp" |
|||
android:paddingVertical="16dp"> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/text_container" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginEnd="16dp" |
|||
android:orientation="vertical" |
|||
app:layout_constraintBottom_toBottomOf="@+id/addon_switch" |
|||
app:layout_constraintEnd_toStartOf="@+id/addon_switch" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="@+id/addon_switch"> |
|||
|
|||
<com.google.android.material.textview.MaterialTextView |
|||
android:id="@+id/title" |
|||
style="@style/TextAppearance.Material3.HeadlineMedium" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:textAlignment="viewStart" |
|||
android:textSize="17sp" |
|||
app:lineHeight="28dp" |
|||
tools:text="1440p Resolution" /> |
|||
|
|||
<com.google.android.material.textview.MaterialTextView |
|||
android:id="@+id/version" |
|||
style="@style/TextAppearance.Material3.BodySmall" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="@dimen/spacing_small" |
|||
android:textAlignment="viewStart" |
|||
tools:text="1.0.0" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<com.google.android.material.materialswitch.MaterialSwitch |
|||
android:id="@+id/addon_switch" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:focusable="true" |
|||
android:gravity="center" |
|||
android:nextFocusLeft="@id/addon_container" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toEndOf="@id/text_container" |
|||
app:layout_constraintTop_toTopOf="parent" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue