android: MainActivity overhaul

This moves several parts of the main activity into fragments that manage themselves to react to changes. UI changes like the appearance of a new search view or when the games list changes now gets updated via multiple view models. This also starts a conversion to the androidx navigation component which furthers the goals mentioned previously with more fragment responsibility. This will eventually allow us to use one activity with interchanging fragments and multiple view models that are stored within that central activity.

fdas
master
Charles Lombardo 2023-04-05 20:26:53 +07:00 committed by bunnei
parent 859c40f00e
commit 233ae9ab69
32 changed files with 1030 additions and 625 deletions

@ -155,6 +155,9 @@ dependencies {
implementation("org.ini4j:ini4j:0.5.4")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.navigation:navigation-fragment-ktx:2.5.3")
implementation("androidx.navigation:navigation-ui-ktx:2.5.3")
implementation("info.debatty:java-string-similarity:2.0.0")
}
fun getVersion(): String {

@ -13,7 +13,6 @@ import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider.OnChangeListener
@ -202,7 +201,7 @@ open class EmulationActivity : AppCompatActivity() {
private const val EMULATION_RUNNING_NOTIFICATION = 0x1000
@JvmStatic
fun launch(activity: FragmentActivity, game: Game) {
fun launch(activity: AppCompatActivity, game: Game) {
val launcher = Intent(activity, EmulationActivity::class.java)
launcher.putExtra(EXTRA_SELECTED_GAME, game)
activity.startActivity(launcher)

@ -3,6 +3,7 @@
package org.yuzu.yuzu_emu.adapters
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.LayoutInflater
@ -11,29 +12,25 @@ import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.CardGameBinding
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.model.Game
import kotlin.collections.ArrayList
import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
/**
* This adapter gets its information from a database Cursor. This fact, paired with the usage of
* ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
* large dataset.
*/
class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<Game>) :
RecyclerView.Adapter<GameAdapter.GameViewHolder>(),
class GameAdapter(private val activity: AppCompatActivity) :
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
View.OnClickListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
// Create a new view.
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.setOnClickListener(this)
// Use that view to create a ViewHolder.
@ -41,12 +38,10 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
}
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
holder.bind(games[position])
holder.bind(currentList[position])
}
override fun getItemCount(): Int {
return games.size
}
override fun getItemCount(): Int = currentList.size
/**
* Launches the game that was clicked on.
@ -55,7 +50,7 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
*/
override fun onClick(view: View) {
val holder = view.tag as GameViewHolder
EmulationActivity.launch((view.context as AppCompatActivity), holder.game)
EmulationActivity.launch(activity, holder.game)
}
inner class GameViewHolder(val binding: CardGameBinding) :
@ -74,7 +69,6 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
val bitmap = decodeGameIcon(game.path)
binding.imageGameScreen.load(bitmap) {
error(R.drawable.no_icon)
crossfade(true)
}
}
@ -87,9 +81,15 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
}
}
fun swapData(games: ArrayList<Game>) {
this.games = games
notifyDataSetChanged()
private class DiffCallback : DiffUtil.ItemCallback<Game>() {
override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem.gameId == newItem.gameId
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem == newItem
}
}
private fun decodeGameIcon(uri: String): Bitmap? {

@ -0,0 +1,55 @@
package org.yuzu.yuzu_emu.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
import org.yuzu.yuzu_emu.model.HomeOption
class HomeOptionAdapter(private val activity: AppCompatActivity, var options: List<HomeOption>) :
RecyclerView.Adapter<HomeOptionAdapter.HomeOptionViewHolder>(),
View.OnClickListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
val binding = CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.setOnClickListener(this)
return HomeOptionViewHolder(binding)
}
override fun getItemCount(): Int {
return options.size
}
override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
holder.bind(options[position])
}
override fun onClick(view: View) {
val holder = view.tag as HomeOptionViewHolder
holder.option.onClick.invoke()
}
inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
RecyclerView.ViewHolder(binding.root) {
lateinit var option: HomeOption
init {
itemView.tag = this
}
fun bind(option: HomeOption) {
this.option = option
binding.optionTitle.text = activity.resources.getString(option.titleId)
binding.optionDescription.text = activity.resources.getString(option.descriptionId)
binding.optionIcon.setImageDrawable(
ResourcesCompat.getDrawable(
activity.resources,
option.iconId,
activity.theme
)
)
}
}
}

@ -15,6 +15,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import com.google.android.material.color.MaterialColors
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
@ -50,6 +51,11 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
setSupportActionBar(binding.toolbarSettings)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
ThemeHelper.setNavigationBarColor(
this,
MaterialColors.getColor(window.decorView, R.attr.colorSurface)
)
setInsets()
}

@ -0,0 +1,281 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.HomeOptionAdapter
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
import org.yuzu.yuzu_emu.databinding.FragmentOptionsBinding
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeOption
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.FileUtil
import org.yuzu.yuzu_emu.utils.GameHelper
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
import java.io.IOException
class OptionsFragment : Fragment() {
private var _binding: FragmentOptionsBinding? = null
private val binding get() = _binding!!
private val gamesViewModel: GamesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentOptionsBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val optionsList: List<HomeOption> = listOf(
HomeOption(
R.string.add_games,
R.string.add_games_description,
R.drawable.ic_add
) { getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
HomeOption(
R.string.install_prod_keys,
R.string.install_prod_keys_description,
R.drawable.ic_unlock
) { getProdKey.launch(arrayOf("*/*")) },
HomeOption(
R.string.install_amiibo_keys,
R.string.install_amiibo_keys_description,
R.drawable.ic_nfc
) { getAmiiboKey.launch(arrayOf("*/*")) },
HomeOption(
R.string.install_gpu_driver,
R.string.install_gpu_driver_description,
R.drawable.ic_input
) { driverInstaller() },
HomeOption(
R.string.settings,
R.string.settings_description,
R.drawable.ic_settings
) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }
)
binding.optionsList.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = HomeOptionAdapter(requireActivity() as AppCompatActivity, optionsList)
}
requireActivity().window.statusBarColor = ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.root,
R.attr.colorSurface
), ThemeHelper.SYSTEM_BAR_ALPHA
)
setInsets()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun driverInstaller() {
// Get the driver name for the dialog message.
var driverName = GpuDriverHelper.customDriverName
if (driverName == null) {
driverName = getString(R.string.system_gpu_driver)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.select_gpu_driver_title))
.setMessage(driverName)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
GpuDriverHelper.installDefaultDriver(requireContext())
Toast.makeText(
requireContext(),
R.string.select_gpu_driver_use_default,
Toast.LENGTH_SHORT
).show()
}
.setNeutralButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
getDriver.launch(arrayOf("application/zip"))
}
.show()
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollViewOptions) { view: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
insets.left,
insets.top,
insets.right,
insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation)
)
windowInsets
}
private val getGamesDirectory =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
requireActivity().contentResolver.takePersistableUriPermission(
result,
takeFlags
)
// When a new directory is picked, we currently will reset the existing games
// database. This effectively means that only one game directory is supported.
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
.putString(GameHelper.KEY_GAME_PATH, result.toString())
.apply()
gamesViewModel.reloadGames(true)
}
private val getProdKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
requireActivity().contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(requireContext(), result, dstPath, "prod.keys")) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
requireContext(),
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
gamesViewModel.reloadGames(true)
} else {
Toast.makeText(
requireContext(),
R.string.install_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
private val getAmiiboKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
requireActivity().contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(
requireContext(),
result,
dstPath,
"key_retail.bin"
)
) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
requireContext(),
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
requireContext(),
R.string.install_amiibo_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
private val getDriver =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
requireActivity().contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
progressBinding.progressBar.isIndeterminate = true
val installationDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.installing_driver)
.setView(progressBinding.root)
.show()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
// Ignore file exceptions when a user selects an invalid zip
try {
GpuDriverHelper.installCustomDriver(requireContext(), result)
} catch (_: IOException) {
}
withContext(Dispatchers.Main) {
installationDialog.dismiss()
val driverName = GpuDriverHelper.customDriverName
if (driverName != null) {
Toast.makeText(
requireContext(),
getString(
R.string.select_gpu_driver_install_success,
driverName
),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
requireContext(),
R.string.select_gpu_driver_error,
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}

@ -1,18 +1,58 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.utils.GameHelper
class GamesViewModel : ViewModel() {
private val _games = MutableLiveData<ArrayList<Game>>()
val games: LiveData<ArrayList<Game>> get() = _games
private val _games = MutableLiveData<List<Game>>(emptyList())
val games: LiveData<List<Game>> get() = _games
private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
val searchedGames: LiveData<List<Game>> get() = _searchedGames
private val _isReloading = MutableLiveData(false)
val isReloading: LiveData<Boolean> get() = _isReloading
private val _shouldSwapData = MutableLiveData(false)
val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
init {
_games.value = ArrayList()
reloadGames(false)
}
fun setGames(games: ArrayList<Game>) {
_games.value = games
fun setSearchedGames(games: List<Game>) {
_searchedGames.postValue(games)
}
fun setShouldSwapData(shouldSwap: Boolean) {
_shouldSwapData.postValue(shouldSwap)
}
fun reloadGames(directoryChanged: Boolean) {
if (isReloading.value == true)
return
_isReloading.postValue(true)
viewModelScope.launch {
withContext(Dispatchers.IO) {
NativeLibrary.resetRomMetadata()
_games.postValue(GameHelper.getGames())
_isReloading.postValue(false)
if (directoryChanged) {
setShouldSwapData(true)
}
}
}
}
}

@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
data class HomeOption(
val titleId: Int,
val descriptionId: Int,
val iconId: Int,
val onClick: () -> Unit
)

@ -0,0 +1,17 @@
package org.yuzu.yuzu_emu.model
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() {
private val _navigationVisible = MutableLiveData(true)
val navigationVisible: LiveData<Boolean> get() = _navigationVisible
fun setNavigationVisible(visible: Boolean) {
if (_navigationVisible.value == visible) {
return
}
_navigationVisible.value = visible
}
}

@ -0,0 +1,220 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.color.MaterialColors
import com.google.android.material.search.SearchView
import com.google.android.material.search.SearchView.TransitionState
import info.debatty.java.stringsimilarity.Jaccard
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.GameAdapter
import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.utils.ThemeHelper
import java.util.Locale
class GamesFragment : Fragment() {
private var _binding: FragmentGamesBinding? = null
private val binding get() = _binding!!
private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentGamesBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Use custom back navigation so the user doesn't back out of the app when trying to back
// out of the search view
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (binding.searchView.currentTransitionState == TransitionState.SHOWN) {
binding.searchView.hide()
} else {
requireActivity().finish()
}
}
})
binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter = GameAdapter(requireActivity() as AppCompatActivity)
}
setUpSearch()
// Add swipe down to refresh gesture
binding.swipeRefresh.setOnRefreshListener {
gamesViewModel.reloadGames(false)
}
// Set theme color to the refresh animation's background
binding.swipeRefresh.setProgressBackgroundColorSchemeColor(
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorPrimary)
)
binding.swipeRefresh.setColorSchemeColors(
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
)
// Watch for when we get updates to any of our games lists
gamesViewModel.isReloading.observe(viewLifecycleOwner) { isReloading ->
binding.swipeRefresh.isRefreshing = isReloading
if (!isReloading) {
if (gamesViewModel.games.value!!.isEmpty()) {
binding.noticeText.visibility = View.VISIBLE
} else {
binding.noticeText.visibility = View.GONE
}
}
}
gamesViewModel.games.observe(viewLifecycleOwner) {
(binding.gridGames.adapter as GameAdapter).submitList(it)
}
gamesViewModel.searchedGames.observe(viewLifecycleOwner) {
(binding.gridSearch.adapter as GameAdapter).submitList(it)
}
gamesViewModel.shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
if (shouldSwapData) {
(binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value)
gamesViewModel.setShouldSwapData(false)
}
}
// Hide bottom navigation and FAB when using the search view
binding.searchView.addTransitionListener { _: SearchView, _: TransitionState, newState: TransitionState ->
when (newState) {
TransitionState.SHOWING,
TransitionState.SHOWN -> {
(binding.gridSearch.adapter as GameAdapter).submitList(emptyList())
searchShown()
}
TransitionState.HIDDEN,
TransitionState.HIDING -> {
gamesViewModel.setSearchedGames(emptyList())
searchHidden()
}
}
}
// Ensure that bottom navigation or FAB don't appear upon recreation
val searchState = binding.searchView.currentTransitionState
if (searchState == TransitionState.SHOWN) {
searchShown()
} else if (searchState == TransitionState.HIDDEN) {
searchHidden()
}
setInsets()
// Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
binding.swipeRefresh.post {
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun searchShown() {
homeViewModel.setNavigationVisible(false)
requireActivity().window.statusBarColor =
ContextCompat.getColor(requireContext(), android.R.color.transparent)
}
private fun searchHidden() {
homeViewModel.setNavigationVisible(true)
requireActivity().window.statusBarColor = ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.root,
R.attr.colorSurface
), ThemeHelper.SYSTEM_BAR_ALPHA
)
}
private inner class ScoredGame(val score: Double, val item: Game)
private fun setUpSearch() {
binding.gridSearch.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter = GameAdapter(requireActivity() as AppCompatActivity)
}
binding.searchView.editText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
val searchTerm = text.toString().lowercase(Locale.getDefault())
val searchAlgorithm = Jaccard(2)
val sortedList: List<Game> = gamesViewModel.games.value!!.mapNotNull { game ->
val title = game.title.lowercase(Locale.getDefault())
val score = searchAlgorithm.similarity(searchTerm, title)
if (score > 0.03) {
ScoredGame(score, game)
} else {
null
}
}.sortedByDescending { it.score }.map { it.item }
gamesViewModel.setSearchedGames(sortedList)
}
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.gridGames) { view: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
view.setPadding(
insets.left,
insets.top + resources.getDimensionPixelSize(R.dimen.spacing_search),
insets.right,
insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation) + extraListSpacing
)
binding.gridSearch.updatePadding(
left = insets.left,
top = extraListSpacing,
right = insets.right,
bottom = insets.bottom + extraListSpacing
)
binding.swipeRefresh.setSlingshotDistance(
resources.getDimensionPixelSize(R.dimen.spacing_refresh_slingshot)
)
binding.swipeRefresh.setProgressViewOffset(
false,
insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_start),
insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
)
windowInsets
}
}

@ -3,42 +3,31 @@
package org.yuzu.yuzu_emu.ui.main
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.ViewGroup.MarginLayoutParams
import android.view.animation.PathInterpolator
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.NativeLibrary
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.color.MaterialColors
import com.google.android.material.elevation.ElevationOverlayProvider
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
import org.yuzu.yuzu_emu.ui.platform.PlatformGamesFragment
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.utils.*
import java.io.IOException
class MainActivity : AppCompatActivity(), MainView {
private var platformGamesFragment: PlatformGamesFragment? = null
private val presenter = MainPresenter(this)
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val homeViewModel: HomeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@ -52,19 +41,36 @@ class MainActivity : AppCompatActivity(), MainView {
WindowCompat.setDecorFitsSystemWindows(window, false)
setSupportActionBar(binding.toolbarMain)
presenter.onCreate()
if (savedInstanceState == null) {
StartupHandler.handleInit(this)
platformGamesFragment = PlatformGamesFragment()
supportFragmentManager.beginTransaction()
.add(R.id.games_platform_frame, platformGamesFragment!!)
.commit()
} else {
platformGamesFragment = supportFragmentManager.getFragment(
savedInstanceState,
PlatformGamesFragment.TAG
) as PlatformGamesFragment?
ThemeHelper.setNavigationBarColor(
this,
ElevationOverlayProvider(binding.navigationBar.context).compositeOverlay(
MaterialColors.getColor(binding.navigationBar, R.attr.colorSurface),
binding.navigationBar.elevation
)
)
// Set up a central host fragment that is controlled via bottom navigation with xml navigation
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
binding.navigationBar.setupWithNavController(navHostFragment.navController)
binding.statusBarShade.setBackgroundColor(
ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.root,
R.attr.colorSurface
), ThemeHelper.SYSTEM_BAR_ALPHA
)
)
// Prevents navigation from being drawn for a short time on recreation if set to hidden
if (homeViewModel.navigationVisible.value == false) {
binding.navigationBar.visibility = View.INVISIBLE
binding.statusBarShade.visibility = View.INVISIBLE
}
homeViewModel.navigationVisible.observe(this) { visible ->
showNavigation(visible)
}
// Dismiss previous notifications (should not happen unless a crash occurred)
@ -73,78 +79,24 @@ class MainActivity : AppCompatActivity(), MainView {
setInsets()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
supportFragmentManager.putFragment(
outState,
PlatformGamesFragment.TAG,
platformGamesFragment!!
)
private fun showNavigation(visible: Boolean) {
binding.navigationBar.animate().apply {
if (visible) {
binding.navigationBar.visibility = View.VISIBLE
binding.navigationBar.translationY = binding.navigationBar.height.toFloat() * 2
duration = 300
translationY(0f)
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
} else {
duration = 300
translationY(binding.navigationBar.height.toFloat() * 2)
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_game_grid, menu)
return true
}
/**
* MainView
*/
override fun setVersionString(version: String) {
binding.toolbarMain.subtitle = version
}
override fun launchSettingsActivity(menuTag: String) {
SettingsActivity.launch(this, menuTag, "")
}
override fun launchFileListActivity(request: Int) {
when (request) {
MainPresenter.REQUEST_ADD_DIRECTORY -> getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
MainPresenter.REQUEST_INSTALL_KEYS -> getProdKey.launch(arrayOf("*/*"))
MainPresenter.REQUEST_INSTALL_AMIIBO_KEYS -> getAmiiboKey.launch(arrayOf("*/*"))
MainPresenter.REQUEST_SELECT_GPU_DRIVER -> {
// Get the driver name for the dialog message.
var driverName = GpuDriverHelper.customDriverName
if (driverName == null) {
driverName = getString(R.string.system_gpu_driver)
}
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.select_gpu_driver_title))
.setMessage(driverName)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
GpuDriverHelper.installDefaultDriver(this)
Toast.makeText(
this,
R.string.select_gpu_driver_use_default,
Toast.LENGTH_SHORT
).show()
}
.setNeutralButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
getDriver.launch(arrayOf("application/zip"))
}
.show()
}
}
}
/**
* Called by the framework whenever any actionbar/toolbar icon is clicked.
*
* @param item The icon that was clicked on.
* @return True if the event was handled, false to bubble it up to the OS.
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return presenter.handleOptionSelection(item.itemId)
}
private fun refreshFragment() {
if (platformGamesFragment != null) {
NativeLibrary.resetRomMetadata()
platformGamesFragment!!.refresh()
}.withEndAction {
if (!visible) {
binding.navigationBar.visibility = View.INVISIBLE
}
}.start()
}
override fun onDestroy() {
@ -152,145 +104,12 @@ class MainActivity : AppCompatActivity(), MainView {
super.onDestroy()
}
private fun setInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.gamesPlatformFrame) { view: View, windowInsets: WindowInsetsCompat ->
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.statusBarShade) { view: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(left = insets.left, right = insets.right)
InsetsHelper.insetAppBar(insets, binding.appbarMain)
val mlpShade = view.layoutParams as MarginLayoutParams
mlpShade.height = insets.top
binding.statusBarShade.layoutParams = mlpShade
windowInsets
}
}
private val getGamesDirectory =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
// When a new directory is picked, we currently will reset the existing games
// database. This effectively means that only one game directory is supported.
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
.putString(GameHelper.KEY_GAME_PATH, result.toString())
.apply()
}
private val getProdKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(this, result, dstPath, "prod.keys")) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
this,
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
refreshFragment()
} else {
Toast.makeText(
this,
R.string.install_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
private val getAmiiboKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(this, result, dstPath, "key_retail.bin")) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
this,
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
refreshFragment()
} else {
Toast.makeText(
this,
R.string.install_amiibo_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
private val getDriver =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
progressBinding.progressBar.isIndeterminate = true
val installationDialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.installing_driver)
.setView(progressBinding.root)
.show()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
// Ignore file exceptions when a user selects an invalid zip
try {
GpuDriverHelper.installCustomDriver(applicationContext, result)
} catch (_: IOException) {
}
withContext(Dispatchers.Main) {
installationDialog.dismiss()
val driverName = GpuDriverHelper.customDriverName
if (driverName != null) {
Toast.makeText(
applicationContext,
getString(
R.string.select_gpu_driver_install_success,
driverName
),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
applicationContext,
R.string.select_gpu_driver_error,
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}

@ -1,52 +0,0 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui.main
import org.yuzu.yuzu_emu.BuildConfig
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
class MainPresenter(private val view: MainView) {
fun onCreate() {
val versionName = BuildConfig.VERSION_NAME
view.setVersionString(versionName)
}
private fun launchFileListActivity(request: Int) {
view.launchFileListActivity(request)
}
fun handleOptionSelection(itemId: Int): Boolean {
when (itemId) {
R.id.menu_settings_core -> {
view.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG)
return true
}
R.id.button_add_directory -> {
launchFileListActivity(REQUEST_ADD_DIRECTORY)
return true
}
R.id.button_install_keys -> {
launchFileListActivity(REQUEST_INSTALL_KEYS)
return true
}
R.id.button_install_amiibo_keys -> {
launchFileListActivity(REQUEST_INSTALL_AMIIBO_KEYS)
return true
}
R.id.button_select_gpu_driver -> {
launchFileListActivity(REQUEST_SELECT_GPU_DRIVER)
return true
}
}
return false
}
companion object {
const val REQUEST_ADD_DIRECTORY = 1
const val REQUEST_INSTALL_KEYS = 2
const val REQUEST_INSTALL_AMIIBO_KEYS = 3
const val REQUEST_SELECT_GPU_DRIVER = 4
}
}

@ -1,23 +0,0 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui.main
/**
* Abstraction for the screen that shows on application launch.
* Implementations will differ primarily to target touch-screen
* or non-touch screen devices.
*/
interface MainView {
/**
* Pass the view the native library's version string. Displaying
* it is optional.
*
* @param version A string pulled from native code.
*/
fun setVersionString(version: String)
fun launchSettingsActivity(menuTag: String)
fun launchFileListActivity(request: Int)
}

@ -1,109 +0,0 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui.platform
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.color.MaterialColors
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.GameAdapter
import org.yuzu.yuzu_emu.databinding.FragmentGridBinding
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.GameHelper
class PlatformGamesFragment : Fragment() {
private var _binding: FragmentGridBinding? = null
private val binding get() = _binding!!
private lateinit var gamesViewModel: GamesViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentGridBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
gamesViewModel = ViewModelProvider(requireActivity())[GamesViewModel::class.java]
binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter =
GameAdapter(requireActivity() as AppCompatActivity, gamesViewModel.games.value!!)
}
// Add swipe down to refresh gesture
binding.swipeRefresh.setOnRefreshListener {
refresh()
binding.swipeRefresh.isRefreshing = false
}
// Set theme color to the refresh animation's background
binding.swipeRefresh.setProgressBackgroundColorSchemeColor(
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorPrimary)
)
binding.swipeRefresh.setColorSchemeColors(
MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
)
gamesViewModel.games.observe(viewLifecycleOwner) {
(binding.gridGames.adapter as GameAdapter).swapData(it)
updateTextView()
}
setInsets()
refresh()
}
override fun onResume() {
super.onResume()
refresh()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
fun refresh() {
gamesViewModel.setGames(GameHelper.getGames())
updateTextView()
}
private fun updateTextView() {
if (_binding == null)
return
binding.gamelistEmptyText.visibility =
if ((binding.gridGames.adapter as GameAdapter).itemCount == 0) View.VISIBLE else View.GONE
}
private fun setInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.gridGames) { view: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(bottom = insets.bottom)
windowInsets
}
}
companion object {
const val TAG = "PlatformGamesFragment"
}
}

@ -1,48 +0,0 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import androidx.preference.PreferenceManager
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.View
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.ui.main.MainPresenter
object StartupHandler {
private val preferences =
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
private fun handleStartupPromptDismiss(parent: MainActivity) {
parent.launchFileListActivity(MainPresenter.REQUEST_INSTALL_KEYS)
}
private fun markFirstBoot() {
preferences.edit()
.putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
.apply()
}
fun handleInit(parent: MainActivity) {
if (preferences.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)) {
markFirstBoot()
val alert = MaterialAlertDialogBuilder(parent)
.setMessage(Html.fromHtml(parent.resources.getString(R.string.app_disclaimer)))
.setTitle(R.string.app_name)
.setIcon(R.drawable.ic_launcher)
.setPositiveButton(android.R.string.ok, null)
.setOnDismissListener {
handleStartupPromptDismiss(parent)
}
.show()
(alert.findViewById<View>(android.R.id.message) as TextView?)!!.movementMethod =
LinkMovementMethod.getInstance()
}
}
}

@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.R
import kotlin.math.roundToInt
object ThemeHelper {
private const val NAV_BAR_ALPHA = 0.9f
const val SYSTEM_BAR_ALPHA = 0.9f
@JvmStatic
fun setTheme(activity: AppCompatActivity) {
@ -29,10 +29,6 @@ object ThemeHelper {
windowController.isAppearanceLightNavigationBars = isLightMode
activity.window.statusBarColor = ContextCompat.getColor(activity, android.R.color.transparent)
val navigationBarColor =
MaterialColors.getColor(activity.window.decorView, R.attr.colorSurface)
setNavigationBarColor(activity, navigationBarColor)
}
@JvmStatic
@ -48,7 +44,7 @@ object ThemeHelper {
} else if (gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION ||
gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION
) {
activity.window.navigationBarColor = getColorWithOpacity(color, NAV_BAR_ALPHA)
activity.window.navigationBarColor = getColorWithOpacity(color, SYSTEM_BAR_ALPHA)
} else {
activity.window.navigationBarColor = ContextCompat.getColor(
activity.applicationContext,
@ -58,7 +54,7 @@ object ThemeHelper {
}
@ColorInt
private fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
return Color.argb(
(alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color),
Color.green(color), Color.blue(color)

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M21,3.01H3c-1.1,0 -2,0.9 -2,2V9h2V4.99h18v14.03H3V15H1v4.01c0,1.1 0.9,1.98 2,1.98h18c1.1,0 2,-0.88 2,-1.98v-14c0,-1.11 -0.9,-2 -2,-2zM11,16l4,-4 -4,-4v3H1v2h10v3z" />
</vector>

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,20L4,20L4,4h16v16zM18,6h-5c-1.1,0 -2,0.9 -2,2v2.28c-0.6,0.35 -1,0.98 -1,1.72 0,1.1 0.9,2 2,2s2,-0.9 2,-2c0,-0.74 -0.4,-1.38 -1,-1.72L13,8h3v8L8,16L8,8h2L10,6L6,6v12h12L18,6z" />
</vector>

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
</vector>

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z" />
</vector>

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="614.697dp"
android:height="683dp"
android:viewportWidth="614.4"
android:viewportHeight="682.67">
<group>
<clip-path android:pathData="M-43,-46.67h699.6v777.33h-699.6z" />
<path
android:fillColor="?attr/colorPrimary"
android:pathData="M340.81,138V682.08c150.26,0 272.06,-121.81 272.06,-272.06S491.07,138 340.81,138M394,197.55a219.06,219.06 0,0 1,0 424.94V197.55" />
</group>
<group>
<clip-path android:pathData="M-43,-46.67h699.6v777.33h-699.6z" />
<path
android:fillColor="?attr/colorPrimary"
android:pathData="M272.79,1.92C122.53,1.92 0.73,123.73 0.73,274s121.8,272.07 272.06,272.07ZM219.65,61.51v425A219,219 0,0 1,118 119.18,217.51 217.51,0 0,1 219.65,61.51" />
</group>
</vector>

@ -1,28 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<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/coordinator_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_main"
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/home_navigation"
tools:layout="@layout/fragment_games" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/navigation_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:liftOnScrollTargetViewId="@id/grid_games">
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/menu_navigation" />
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar_main"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/games_platform_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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"
style="?attr/materialCardViewFilledStyle"
android:id="@+id/option_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/option_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="28dp"
android:layout_gravity="center_vertical"
app:tint="?attr/colorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodyMedium"
android:id="@+id/option_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
tools:text="@string/install_prod_keys" />
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodySmall"
android:id="@+id/option_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
tools:text="@string/install_prod_keys_description" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

@ -0,0 +1,80 @@
<?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_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:layout_behavior="@string/searchbar_scrolling_view_behavior">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/notice_text"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="@dimen/spacing_large"
android:text="@string/empty_gamelist"
tools:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/grid_games"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@layout/card_game" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:liftOnScrollTargetViewId="@id/grid_games">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/home_search_games" />
</FrameLayout>
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.search.SearchView
android:id="@+id/search_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:hint="@string/home_search_games"
app:layout_anchor="@id/search_bar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/grid_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@layout/card_game" />
</com.google.android.material.search.SearchView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/gamelist_empty_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/empty_gamelist"
android:textSize="18sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/grid_games"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@layout/card_game" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scroll_view_options"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<ImageView
android:layout_width="128dp"
android:layout_height="128dp"
android:layout_margin="64dp"
android:layout_gravity="center_horizontal"
android:src="@drawable/ic_yuzu_themed" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/options_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.core.widget.NestedScrollView>

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/button_file_menu"
android:icon="@drawable/ic_folder"
android:title="@string/select_game_folder"
app:showAsAction="ifRoom">
<menu>
<item
android:id="@+id/button_add_directory"
android:icon="@drawable/ic_folder"
android:title="@string/select_game_folder"
app:showAsAction="ifRoom" />
<item
android:id="@+id/button_install_keys"
android:icon="@drawable/ic_install"
android:title="@string/install_keys"
app:showAsAction="ifRoom" />
<item
android:id="@+id/button_install_amiibo_keys"
android:icon="@drawable/ic_install"
android:title="@string/install_amiibo_keys"
app:showAsAction="ifRoom" />
<item
android:id="@+id/button_select_gpu_driver"
android:icon="@drawable/ic_settings"
android:title="@string/select_gpu_driver"
app:showAsAction="ifRoom" />
</menu>
</item>
<item
android:id="@+id/menu_settings_core"
android:icon="@drawable/ic_settings"
android:title="@string/grid_menu_core_settings"
app:showAsAction="ifRoom" />
</menu>

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/gamesFragment"
android:icon="@drawable/ic_controller"
android:title="@string/home_games" />
<item
android:id="@+id/optionsFragment"
android:icon="@drawable/ic_options"
android:title="@string/home_options" />
</menu>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/home_navigation"
app:startDestination="@id/gamesFragment">
<fragment
android:id="@+id/gamesFragment"
android:name="org.yuzu.yuzu_emu.ui.GamesFragment"
android:label="PlatformGamesFragment" />
<fragment
android:id="@+id/optionsFragment"
android:name="org.yuzu.yuzu_emu.fragments.OptionsFragment"
android:label="OptionsFragment" />
</navigation>

@ -1,10 +1,15 @@
<resources>
<dimen name="spacing_small">4dp</dimen>
<dimen name="spacing_med">8dp</dimen>
<dimen name="spacing_medlarge">12dp</dimen>
<dimen name="spacing_large">16dp</dimen>
<dimen name="spacing_xtralarge">32dp</dimen>
<dimen name="spacing_list">64dp</dimen>
<dimen name="spacing_fab">72dp</dimen>
<dimen name="spacing_navigation">80dp</dimen>
<dimen name="spacing_search">88dp</dimen>
<dimen name="spacing_refresh_slingshot">80dp</dimen>
<dimen name="spacing_refresh_start">32dp</dimen>
<dimen name="spacing_refresh_end">96dp</dimen>
<dimen name="menu_width">256dp</dimen>
<dimen name="card_width">160dp</dimen>

@ -9,6 +9,24 @@
<string name="app_notification_channel_description">yuzu Switch emulator notifications</string>
<string name="app_notification_running">yuzu is running</string>
<!-- Home strings -->
<string name="home_games">Games</string>
<string name="home_options">Options</string>
<string name="add_games">Add Games</string>
<string name="add_games_description">Select your games folder</string>
<string name="home_search_games">Search Games</string>
<string name="install_prod_keys">Install Prod.keys</string>
<string name="install_prod_keys_description">Required to decrypt retail games</string>
<string name="install_amiibo_keys">Install Amiibo Keys</string>
<string name="install_amiibo_keys_description">Required to use Amiibo in game</string>
<string name="install_keys_success">Keys successfully installed</string>
<string name="install_keys_failure">Keys file (prod.keys) is invalid</string>
<string name="install_amiibo_keys_failure">Keys file (key_retail.bin) is invalid</string>
<string name="install_gpu_driver">Install GPU Driver</string>
<string name="install_gpu_driver_description">Use a different driver for potentially better performance or accuracy</string>
<string name="settings">Settings</string>
<string name="settings_description">Configure emulator settings</string>
<!-- General settings strings -->
<string name="frame_limit_enable">Enable limit speed</string>
<string name="frame_limit_enable_description">When enabled, emulation speed will be limited to a specified percentage of normal speed.</string>
@ -51,17 +69,6 @@
<string name="error_saving">Error saving %1$s.ini: %2$s</string>
<string name="loading">Loading...</string>
<!-- Game Grid Screen-->
<string name="grid_menu_core_settings">Settings</string>
<!-- Add Directory Screen-->
<string name="select_game_folder">Select game folder</string>
<string name="install_keys">Install keys</string>
<string name="install_amiibo_keys">Install amiibo keys</string>
<string name="install_keys_success">Keys successfully installed</string>
<string name="install_keys_failure">Keys file (prod.keys) is invalid</string>
<string name="install_amiibo_keys_failure">Keys file (key_retail.bin) is invalid</string>
<!-- GPU driver installation -->
<string name="select_gpu_driver">Select GPU driver</string>
<string name="select_gpu_driver_title">Would you like to replace your current GPU driver?</string>