From 233ae9ab691f2e6fe65d97fdd58b2ac6e015ad38 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Wed, 5 Apr 2023 20:26:53 -0400 Subject: [PATCH] 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 --- src/android/app/build.gradle.kts | 3 + .../yuzu_emu/activities/EmulationActivity.kt | 3 +- .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 40 +-- .../yuzu_emu/adapters/HomeOptionAdapter.kt | 55 ++++ .../features/settings/ui/SettingsActivity.kt | 6 + .../yuzu_emu/fragments/OptionsFragment.kt | 281 ++++++++++++++++ .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 50 ++- .../org/yuzu/yuzu_emu/model/HomeOption.kt | 11 + .../org/yuzu/yuzu_emu/model/HomeViewModel.kt | 17 + .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 220 +++++++++++++ .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 307 ++++-------------- .../yuzu/yuzu_emu/ui/main/MainPresenter.kt | 52 --- .../org/yuzu/yuzu_emu/ui/main/MainView.kt | 23 -- .../ui/platform/PlatformGamesFragment.kt | 109 ------- .../org/yuzu/yuzu_emu/utils/StartupHandler.kt | 48 --- .../org/yuzu/yuzu_emu/utils/ThemeHelper.kt | 10 +- .../app/src/main/res/drawable/ic_add.xml | 9 + .../app/src/main/res/drawable/ic_input.xml | 10 + .../app/src/main/res/drawable/ic_nfc.xml | 9 + .../app/src/main/res/drawable/ic_options.xml | 9 + .../app/src/main/res/drawable/ic_unlock.xml | 9 + .../src/main/res/drawable/ic_yuzu_themed.xml | 18 + .../app/src/main/res/layout/activity_main.xml | 42 +-- .../src/main/res/layout/card_home_option.xml | 53 +++ .../src/main/res/layout/fragment_games.xml | 80 +++++ .../app/src/main/res/layout/fragment_grid.xml | 37 --- .../src/main/res/layout/fragment_options.xml | 30 ++ .../app/src/main/res/menu/menu_game_grid.xml | 47 --- .../app/src/main/res/menu/menu_navigation.xml | 14 + .../main/res/navigation/home_navigation.xml | 17 + .../app/src/main/res/values/dimens.xml | 7 +- .../app/src/main/res/values/strings.xml | 29 +- 32 files changed, 1030 insertions(+), 625 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt create mode 100644 src/android/app/src/main/res/drawable/ic_add.xml create mode 100644 src/android/app/src/main/res/drawable/ic_input.xml create mode 100644 src/android/app/src/main/res/drawable/ic_nfc.xml create mode 100644 src/android/app/src/main/res/drawable/ic_options.xml create mode 100644 src/android/app/src/main/res/drawable/ic_unlock.xml create mode 100644 src/android/app/src/main/res/drawable/ic_yuzu_themed.xml create mode 100644 src/android/app/src/main/res/layout/card_home_option.xml create mode 100644 src/android/app/src/main/res/layout/fragment_games.xml delete mode 100644 src/android/app/src/main/res/layout/fragment_grid.xml create mode 100644 src/android/app/src/main/res/layout/fragment_options.xml delete mode 100644 src/android/app/src/main/res/menu/menu_game_grid.xml create mode 100644 src/android/app/src/main/res/menu/menu_navigation.xml create mode 100644 src/android/app/src/main/res/navigation/home_navigation.xml diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 552d4a7215..d8ef02ac13 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -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 { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index f1f92841c6..fd174fd2d8 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -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) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index af83f05c19..1102b60b16 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -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) : - RecyclerView.Adapter(), +class GameAdapter(private val activity: AppCompatActivity) : + ListAdapter(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) { - this.games = games - notifyDataSetChanged() + private class DiffCallback : DiffUtil.ItemCallback() { + 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? { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt new file mode 100644 index 0000000000..2bec2de870 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt @@ -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) : + RecyclerView.Adapter(), + 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 + ) + ) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt index 0f2c238273..e4bdcc9915 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -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() } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt new file mode 100644 index 0000000000..dac9e67d57 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt @@ -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 = 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() + } + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index fde99f1a27..709a5b9761 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -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>() - val games: LiveData> get() = _games + private val _games = MutableLiveData>(emptyList()) + val games: LiveData> get() = _games + + private val _searchedGames = MutableLiveData>(emptyList()) + val searchedGames: LiveData> get() = _searchedGames + + private val _isReloading = MutableLiveData(false) + val isReloading: LiveData get() = _isReloading + + private val _shouldSwapData = MutableLiveData(false) + val shouldSwapData: LiveData get() = _shouldSwapData init { - _games.value = ArrayList() + reloadGames(false) } - fun setGames(games: ArrayList) { - _games.value = games + fun setSearchedGames(games: List) { + _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) + } + } + } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt new file mode 100644 index 0000000000..c995ff12c1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt @@ -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 +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt new file mode 100644 index 0000000000..74f12429cc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -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 get() = _navigationVisible + + fun setNavigationVisible(visible: Boolean) { + if (_navigationVisible.value == visible) { + return + } + _navigationVisible.value = visible + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt new file mode 100644 index 0000000000..0c609798b1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -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 = 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 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 69a3719472..a16ca85290 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -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!! - ) - } - - 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() + 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) } - } - } - - /** - * 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() - } - } - } - } - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt deleted file mode 100644 index a7ddc333f9..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt +++ /dev/null @@ -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 - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt deleted file mode 100644 index 4dc9f07068..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt +++ /dev/null @@ -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) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt deleted file mode 100644 index 443a37cd2b..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt +++ /dev/null @@ -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" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt deleted file mode 100644 index e2e56eb062..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt +++ /dev/null @@ -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(android.R.id.message) as TextView?)!!.movementMethod = - LinkMovementMethod.getInstance() - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt index ce6396e911..481498f7b6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt @@ -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) diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000000..f7deb25320 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_input.xml b/src/android/app/src/main/res/drawable/ic_input.xml new file mode 100644 index 0000000000..c170865efc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_input.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_nfc.xml b/src/android/app/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000000..3dacf798b5 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_options.xml b/src/android/app/src/main/res/drawable/ic_options.xml new file mode 100644 index 0000000000..91d52f1b83 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_options.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_unlock.xml b/src/android/app/src/main/res/drawable/ic_unlock.xml new file mode 100644 index 0000000000..40952cbc51 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_unlock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_themed.xml b/src/android/app/src/main/res/drawable/ic_yuzu_themed.xml new file mode 100644 index 0000000000..4400e9eafc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_yuzu_themed.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml index 059aaa9b4b..9002b0642e 100644 --- a/src/android/app/src/main/res/layout/activity_main.xml +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -1,28 +1,32 @@ - - + + + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:menu="@menu/menu_navigation" /> - - - - - - - + diff --git a/src/android/app/src/main/res/layout/card_home_option.xml b/src/android/app/src/main/res/layout/card_home_option.xml new file mode 100644 index 0000000000..aea3547835 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_home_option.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_games.xml b/src/android/app/src/main/res/layout/fragment_games.xml new file mode 100644 index 0000000000..5cfe76de31 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_games.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_grid.xml b/src/android/app/src/main/res/layout/fragment_grid.xml deleted file mode 100644 index bfb670b6db..0000000000 --- a/src/android/app/src/main/res/layout/fragment_grid.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/android/app/src/main/res/layout/fragment_options.xml b/src/android/app/src/main/res/layout/fragment_options.xml new file mode 100644 index 0000000000..ec6e7c2053 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_options.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml deleted file mode 100644 index 73046de0e9..0000000000 --- a/src/android/app/src/main/res/menu/menu_game_grid.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/android/app/src/main/res/menu/menu_navigation.xml b/src/android/app/src/main/res/menu/menu_navigation.xml new file mode 100644 index 0000000000..ca5a656a6d --- /dev/null +++ b/src/android/app/src/main/res/menu/menu_navigation.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml new file mode 100644 index 0000000000..e85e24a851 --- /dev/null +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index db0a8f7e51..23977c9f19 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -1,10 +1,15 @@ 4dp + 8dp 12dp 16dp 32dp 64dp - 72dp + 80dp + 88dp + 80dp + 32dp + 96dp 256dp 160dp diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 75d1f22931..564bad0816 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -9,6 +9,24 @@ yuzu Switch emulator notifications yuzu is running + + Games + Options + Add Games + Select your games folder + Search Games + Install Prod.keys + Required to decrypt retail games + Install Amiibo Keys + Required to use Amiibo in game + Keys successfully installed + Keys file (prod.keys) is invalid + Keys file (key_retail.bin) is invalid + Install GPU Driver + Use a different driver for potentially better performance or accuracy + Settings + Configure emulator settings + Enable limit speed When enabled, emulation speed will be limited to a specified percentage of normal speed. @@ -51,17 +69,6 @@ Error saving %1$s.ini: %2$s Loading... - - Settings - - - Select game folder - Install keys - Install amiibo keys - Keys successfully installed - Keys file (prod.keys) is invalid - Keys file (key_retail.bin) is invalid - Select GPU driver Would you like to replace your current GPU driver?