From b8f66c9412e2306691ffb9f8a9d232198ebd13f4 Mon Sep 17 00:00:00 2001 From: t895 Date: Mon, 27 Nov 2023 14:56:25 -0500 Subject: [PATCH] android: Multi directory UI --- .../yuzu/yuzu_emu/adapters/FolderAdapter.kt | 76 +++++++++++ .../fragments/AddGameFolderDialogFragment.kt | 53 ++++++++ .../GameFolderPropertiesDialogFragment.kt | 72 ++++++++++ .../yuzu_emu/fragments/GameFoldersFragment.kt | 128 ++++++++++++++++++ .../fragments/HomeSettingsFragment.kt | 13 +- .../yuzu/yuzu_emu/fragments/SetupFragment.kt | 8 +- .../java/org/yuzu/yuzu_emu/model/GameDir.kt | 13 ++ .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 61 ++++++++- .../org/yuzu/yuzu_emu/model/HomeViewModel.kt | 19 --- .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 26 ++-- .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 21 +++ .../org/yuzu/yuzu_emu/utils/GameHelper.kt | 39 +++++- .../org/yuzu/yuzu_emu/utils/NativeConfig.kt | 20 +++ .../app/src/main/jni/android_config.cpp | 50 +++++++ src/android/app/src/main/jni/android_config.h | 8 +- .../app/src/main/jni/android_settings.h | 8 ++ src/android/app/src/main/jni/id_cache.cpp | 16 +++ src/android/app/src/main/jni/id_cache.h | 2 + .../app/src/main/jni/native_config.cpp | 52 +++++++ .../app/src/main/res/layout/card_folder.xml | 70 ++++++++++ .../src/main/res/layout/dialog_add_folder.xml | 45 ++++++ .../res/layout/dialog_folder_properties.xml | 30 ++++ .../src/main/res/layout/fragment_folders.xml | 48 +++++++ .../main/res/navigation/home_navigation.xml | 7 + .../app/src/main/res/values/dimens.xml | 2 +- .../app/src/main/res/values/strings.xml | 7 + src/frontend_common/config.cpp | 2 + 27 files changed, 837 insertions(+), 59 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt create mode 100644 src/android/app/src/main/res/layout/card_folder.xml create mode 100644 src/android/app/src/main/res/layout/dialog_add_folder.xml create mode 100644 src/android/app/src/main/res/layout/dialog_folder_properties.xml create mode 100644 src/android/app/src/main/res/layout/fragment_folders.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt new file mode 100644 index 000000000..ab657a7b9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.net.Uri +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.CardFolderBinding +import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel + +class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) : + ListAdapter( + AsyncDifferConfig.Builder(DiffCallback()).build() + ) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): FolderAdapter.FolderViewHolder { + CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return FolderViewHolder(it) } + } + + override fun onBindViewHolder(holder: FolderAdapter.FolderViewHolder, position: Int) = + holder.bind(currentList[position]) + + inner class FolderViewHolder(val binding: CardFolderBinding) : + RecyclerView.ViewHolder(binding.root) { + private lateinit var gameDir: GameDir + + fun bind(gameDir: GameDir) { + this.gameDir = gameDir + + binding.apply { + path.text = Uri.parse(gameDir.uriString).path + path.postDelayed( + { + path.isSelected = true + path.ellipsize = TextUtils.TruncateAt.MARQUEE + }, + 3000 + ) + + buttonEdit.setOnClickListener { + GameFolderPropertiesDialogFragment.newInstance(this@FolderViewHolder.gameDir) + .show( + activity.supportFragmentManager, + GameFolderPropertiesDialogFragment.TAG + ) + } + + buttonDelete.setOnClickListener { + gamesViewModel.removeFolder(this@FolderViewHolder.gameDir) + } + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GameDir, newItem: GameDir): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: GameDir, newItem: GameDir): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt new file mode 100644 index 000000000..dec2b7cf1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogAddFolderBinding +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel + +class AddGameFolderDialogFragment : DialogFragment() { + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogAddFolderBinding.inflate(layoutInflater) + val folderUriString = requireArguments().getString(FOLDER_URI_STRING) + if (folderUriString == null) { + dismiss() + } + binding.path.text = Uri.parse(folderUriString).path + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.add_game_folder) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) + gamesViewModel.addFolder(newGameDir) + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "AddGameFolderDialogFragment" + + private const val FOLDER_URI_STRING = "FolderUriString" + + fun newInstance(folderUriString: String): AddGameFolderDialogFragment { + val args = Bundle() + args.putString(FOLDER_URI_STRING, folderUriString) + val fragment = AddGameFolderDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt new file mode 100644 index 000000000..b6c2e4635 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class GameFolderPropertiesDialogFragment : DialogFragment() { + private val gamesViewModel: GamesViewModel by activityViewModels() + + private var deepScan = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) + val gameDir = requireArguments().parcelable(GAME_DIR)!! + + // Restore checkbox state + binding.deepScanSwitch.isChecked = + savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan + + // Ensure that we can get the checkbox state even if the view is destroyed + deepScan = binding.deepScanSwitch.isChecked + binding.deepScanSwitch.setOnClickListener { + deepScan = binding.deepScanSwitch.isChecked + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.game_folder_properties) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) + if (folderIndex != -1) { + gamesViewModel.folders.value[folderIndex].deepScan = + binding.deepScanSwitch.isChecked + gamesViewModel.updateGameDirs() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(DEEP_SCAN, deepScan) + } + + companion object { + const val TAG = "GameFolderPropertiesDialogFragment" + + private const val GAME_DIR = "GameDir" + + private const val DEEP_SCAN = "DeepScan" + + fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment { + val args = Bundle() + args.putParcelable(GAME_DIR, gameDir) + val fragment = GameFolderPropertiesDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt new file mode 100644 index 000000000..341a37fdb --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.FolderAdapter +import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity + +class GameFoldersFragment : Fragment() { + private var _binding: FragmentFoldersBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + gamesViewModel.onOpenGameFoldersFragment() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFoldersBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarFolders.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.listFolders.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = FolderAdapter(requireActivity(), gamesViewModel) + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + gamesViewModel.folders.collect { + (binding.listFolders.adapter as FolderAdapter).submitList(it) + } + } + } + + val mainActivity = requireActivity() as MainActivity + binding.buttonAdd.setOnClickListener { + mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + + setInsets() + } + + override fun onStop() { + super.onStop() + gamesViewModel.onCloseGameFoldersFragment() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpToolbar = binding.toolbarFolders.layoutParams as ViewGroup.MarginLayoutParams + mlpToolbar.leftMargin = leftInsets + mlpToolbar.rightMargin = rightInsets + binding.toolbarFolders.layoutParams = mlpToolbar + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + val mlpFab = + binding.buttonAdd.layoutParams as ViewGroup.MarginLayoutParams + mlpFab.leftMargin = leftInsets + fabSpacing + mlpFab.rightMargin = rightInsets + fabSpacing + mlpFab.bottomMargin = barInsets.bottom + fabSpacing + binding.buttonAdd.layoutParams = mlpFab + + val mlpListFolders = binding.listFolders.layoutParams as ViewGroup.MarginLayoutParams + mlpListFolders.leftMargin = leftInsets + mlpListFolders.rightMargin = rightInsets + binding.listFolders.layoutParams = mlpListFolders + + binding.listFolders.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 4720daec4..3addc2e63 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -127,18 +127,13 @@ class HomeSettingsFragment : Fragment() { ) add( HomeSetting( - R.string.select_games_folder, + R.string.manage_game_folders, R.string.select_games_folder_description, R.drawable.ic_add, { - mainActivity.getGamesDirectory.launch( - Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data - ) - }, - { true }, - 0, - 0, - homeViewModel.gamesDir + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment) + } ) ) add( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt index c66bb635a..c4277735d 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -42,7 +42,7 @@ import org.yuzu.yuzu_emu.model.SetupPage import org.yuzu.yuzu_emu.model.StepState import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.GameHelper +import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.ViewUtils class SetupFragment : Fragment() { @@ -184,11 +184,7 @@ class SetupFragment : Fragment() { R.string.add_games_warning_description, R.string.add_games_warning_help, { - val preferences = - PreferenceManager.getDefaultSharedPreferences( - YuzuApplication.appContext - ) - if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) { + if (NativeConfig.getGameDirs().isNotEmpty()) { StepState.COMPLETE } else { StepState.INCOMPLETE diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt new file mode 100644 index 000000000..274bc1c7b --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GameDir( + val uriString: String, + var deepScan: Boolean +) : Parcelable 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 8512ed17c..752d98c10 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 @@ -12,6 +12,7 @@ import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString @@ -20,6 +21,7 @@ import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.utils.GameHelper import org.yuzu.yuzu_emu.utils.GameMetadata +import org.yuzu.yuzu_emu.utils.NativeConfig class GamesViewModel : ViewModel() { val games: StateFlow> get() = _games @@ -40,6 +42,9 @@ class GamesViewModel : ViewModel() { val searchFocused: StateFlow get() = _searchFocused private val _searchFocused = MutableStateFlow(false) + private val _folders = MutableStateFlow(mutableListOf()) + val folders = _folders.asStateFlow() + init { // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() @@ -50,6 +55,7 @@ class GamesViewModel : ViewModel() { viewModelScope.launch { withContext(Dispatchers.IO) { + getGameDirs() if (storedGames!!.isNotEmpty()) { val deserializedGames = mutableSetOf() storedGames.forEach { @@ -104,7 +110,7 @@ class GamesViewModel : ViewModel() { _searchFocused.value = searchFocused } - fun reloadGames(directoryChanged: Boolean) { + fun reloadGames(directoriesChanged: Boolean) { if (isReloading.value) { return } @@ -116,10 +122,61 @@ class GamesViewModel : ViewModel() { setGames(GameHelper.getGames()) _isReloading.value = false - if (directoryChanged) { + if (directoriesChanged) { setShouldSwapData(true) } } } } + + fun addFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.addGameDir(gameDir) + getGameDirs() + } + } + + fun removeFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + val gameDirs = _folders.value.toMutableList() + val removedDirIndex = gameDirs.indexOf(gameDir) + if (removedDirIndex != -1) { + gameDirs.removeAt(removedDirIndex) + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + getGameDirs() + } + } + } + + fun updateGameDirs() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.setGameDirs(_folders.value.toTypedArray()) + getGameDirs() + } + } + + fun onOpenGameFoldersFragment() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs() + } + } + + fun onCloseGameFoldersFragment() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs(true) + } + } + + private fun getGameDirs(reloadList: Boolean = false) { + val gameDirs = NativeConfig.getGameDirs() + _folders.value = gameDirs.toMutableList() + if (reloadList) { + reloadGames(true) + } + } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt index 756f76721..251b5a667 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -3,15 +3,9 @@ package org.yuzu.yuzu_emu.model -import android.net.Uri -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.preference.PreferenceManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.utils.GameHelper class HomeViewModel : ViewModel() { val navigationVisible: StateFlow> get() = _navigationVisible @@ -23,14 +17,6 @@ class HomeViewModel : ViewModel() { val shouldPageForward: StateFlow get() = _shouldPageForward private val _shouldPageForward = MutableStateFlow(false) - val gamesDir: StateFlow get() = _gamesDir - private val _gamesDir = MutableStateFlow( - Uri.parse( - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - .getString(GameHelper.KEY_GAME_PATH, "") - ).path ?: "" - ) - var navigatedToSetup = false fun setNavigationVisibility(visible: Boolean, animated: Boolean) { @@ -50,9 +36,4 @@ class HomeViewModel : ViewModel() { fun setShouldPageForward(pageForward: Boolean) { _shouldPageForward.value = pageForward } - - fun setGamesDir(activity: FragmentActivity, dir: String) { - ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) - _gamesDir.value = dir - } } 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 bd2f4cd25..745901e19 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 @@ -40,6 +40,7 @@ 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.features.settings.model.Settings +import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment import org.yuzu.yuzu_emu.fragments.MessageDialogFragment import org.yuzu.yuzu_emu.getPublicFilesDir @@ -293,20 +294,19 @@ class MainActivity : AppCompatActivity(), ThemeProvider { Intent.FLAG_GRANT_READ_URI_PERMISSION ) - // 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() + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } - Toast.makeText( - applicationContext, - R.string.games_dir_selected, - Toast.LENGTH_LONG - ).show() - - gamesViewModel.reloadGames(true) - homeViewModel.setGamesDir(this, result.path!!) + AddGameFolderDialogFragment.newInstance(uriString) + .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) } val getProdKey = diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index 8c3268e9c..bbe7bfa92 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -364,6 +364,27 @@ object FileUtil { .lowercase() } + fun isTreeUriValid(uri: Uri): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + return try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + resolver.query(childrenUri, columns, null, null, null) + true + } catch (_: Exception) { + false + } + } + @Throws(IOException::class) fun getStringFromFile(file: File): String = String(file.readBytes(), StandardCharsets.UTF_8) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index e6aca6b44..55010dc59 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -11,10 +11,11 @@ import kotlinx.serialization.json.Json import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.MinimalDocumentFile object GameHelper { - const val KEY_GAME_PATH = "game_path" + private const val KEY_OLD_GAME_PATH = "game_path" const val KEY_GAMES = "Games" private lateinit var preferences: SharedPreferences @@ -22,15 +23,43 @@ object GameHelper { fun getGames(): List { val games = mutableListOf() val context = YuzuApplication.appContext - val gamesDir = - PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "") - val gamesUri = Uri.parse(gamesDir) preferences = PreferenceManager.getDefaultSharedPreferences(context) + val gameDirs = mutableListOf() + val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: "" + if (oldGamesDir.isNotEmpty()) { + gameDirs.add(GameDir(oldGamesDir, true)) + preferences.edit().remove(KEY_OLD_GAME_PATH).apply() + } + gameDirs.addAll(NativeConfig.getGameDirs()) + // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() - addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3) + val badDirs = mutableListOf() + gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> + val gameDirUri = Uri.parse(gameDir.uriString) + val isValid = FileUtil.isTreeUriValid(gameDirUri) + if (isValid) { + addGamesRecursive( + games, + FileUtil.listFiles(gameDirUri), + if (gameDir.deepScan) 3 else 1 + ) + } else { + badDirs.add(index) + } + } + + // Remove all game dirs with insufficient permissions from config + if (badDirs.isNotEmpty()) { + var offset = 0 + badDirs.forEach { + gameDirs.removeAt(it - offset) + offset++ + } + } + NativeConfig.setGameDirs(gameDirs.toTypedArray()) // Cache list of games found on disk val serializedGames = mutableSetOf() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt index 87e579fa7..f4e1bb13f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt @@ -3,6 +3,8 @@ package org.yuzu.yuzu_emu.utils +import org.yuzu.yuzu_emu.model.GameDir + object NativeConfig { /** * Creates a Config object and opens the emulation config. @@ -54,4 +56,22 @@ object NativeConfig { external fun getConfigHeader(category: Int): String external fun getPairedSettingKey(key: String): String + + /** + * Gets every [GameDir] in AndroidSettings::values.game_dirs + */ + @Synchronized + external fun getGameDirs(): Array + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array) + + /** + * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array + */ + @Synchronized + external fun addGameDir(dir: GameDir) } diff --git a/src/android/app/src/main/jni/android_config.cpp b/src/android/app/src/main/jni/android_config.cpp index 3041c25c9..767d8ea83 100644 --- a/src/android/app/src/main/jni/android_config.cpp +++ b/src/android/app/src/main/jni/android_config.cpp @@ -34,6 +34,7 @@ void AndroidConfig::SaveAllValues() { void AndroidConfig::ReadAndroidValues() { if (global) { ReadAndroidUIValues(); + ReadUIValues(); } } @@ -45,9 +46,35 @@ void AndroidConfig::ReadAndroidUIValues() { EndGroup(); } +void AndroidConfig::ReadUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + ReadPathValues(); + + EndGroup(); +} + +void AndroidConfig::ReadPathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + const int gamedirs_size = BeginArray(std::string("gamedirs")); + for (int i = 0; i < gamedirs_size; ++i) { + SetArrayIndex(i); + AndroidSettings::GameDir game_dir; + game_dir.path = ReadStringSetting(std::string("path")); + game_dir.deep_scan = + ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false)); + AndroidSettings::values.game_dirs.push_back(game_dir); + } + EndArray(); + + EndGroup(); +} + void AndroidConfig::SaveAndroidValues() { if (global) { SaveAndroidUIValues(); + SaveUIValues(); } WriteToIni(); @@ -61,6 +88,29 @@ void AndroidConfig::SaveAndroidUIValues() { EndGroup(); } +void AndroidConfig::SaveUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + SavePathValues(); + + EndGroup(); +} + +void AndroidConfig::SavePathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + BeginArray(std::string("gamedirs")); + for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) { + SetArrayIndex(i); + const auto& game_dir = AndroidSettings::values.game_dirs[i]; + WriteSetting(std::string("path"), game_dir.path); + WriteSetting(std::string("deep_scan"), game_dir.deep_scan, std::make_optional(false)); + } + EndArray(); + + EndGroup(); +} + std::vector& AndroidConfig::FindRelevantList(Settings::Category category) { auto& map = Settings::values.linkage.by_category; if (map.contains(category)) { diff --git a/src/android/app/src/main/jni/android_config.h b/src/android/app/src/main/jni/android_config.h index e679392fd..f490be016 100644 --- a/src/android/app/src/main/jni/android_config.h +++ b/src/android/app/src/main/jni/android_config.h @@ -19,9 +19,9 @@ protected: void ReadAndroidUIValues(); void ReadHidbusValues() override {} void ReadDebugControlValues() override {} - void ReadPathValues() override {} + void ReadPathValues() override; void ReadShortcutValues() override {} - void ReadUIValues() override {} + void ReadUIValues() override; void ReadUIGamelistValues() override {} void ReadUILayoutValues() override {} void ReadMultiplayerValues() override {} @@ -30,9 +30,9 @@ protected: void SaveAndroidUIValues(); void SaveHidbusValues() override {} void SaveDebugControlValues() override {} - void SavePathValues() override {} + void SavePathValues() override; void SaveShortcutValues() override {} - void SaveUIValues() override {} + void SaveUIValues() override; void SaveUIGamelistValues() override {} void SaveUILayoutValues() override {} void SaveMultiplayerValues() override {} diff --git a/src/android/app/src/main/jni/android_settings.h b/src/android/app/src/main/jni/android_settings.h index 37bc33918..fc0523206 100644 --- a/src/android/app/src/main/jni/android_settings.h +++ b/src/android/app/src/main/jni/android_settings.h @@ -9,9 +9,17 @@ namespace AndroidSettings { +struct GameDir { + std::string path; + bool deep_scan = false; +}; + struct Values { Settings::Linkage linkage; + // Path settings + std::vector game_dirs; + // Android Settings::Setting picture_in_picture{linkage, false, "picture_in_picture", Settings::Category::Android}; diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index 960abf95a..a56ed5662 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -13,6 +13,8 @@ static JavaVM* s_java_vm; static jclass s_native_library_class; static jclass s_disk_cache_progress_class; static jclass s_load_callback_stage_class; +static jclass s_game_dir_class; +static jmethodID s_game_dir_constructor; static jmethodID s_exit_emulation_activity; static jmethodID s_disk_cache_load_progress; static jmethodID s_on_emulation_started; @@ -53,6 +55,14 @@ jclass GetDiskCacheLoadCallbackStageClass() { return s_load_callback_stage_class; } +jclass GetGameDirClass() { + return s_game_dir_class; +} + +jmethodID GetGameDirConstructor() { + return s_game_dir_constructor; +} + jmethodID GetExitEmulationActivity() { return s_exit_emulation_activity; } @@ -90,6 +100,11 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass( "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); + const jclass game_dir_class = env->FindClass("org/yuzu/yuzu_emu/model/GameDir"); + s_game_dir_class = reinterpret_cast(env->NewGlobalRef(game_dir_class)); + s_game_dir_constructor = env->GetMethodID(game_dir_class, "", "(Ljava/lang/String;Z)V"); + env->DeleteLocalRef(game_dir_class); + // Initialize methods s_exit_emulation_activity = env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); @@ -120,6 +135,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) { env->DeleteGlobalRef(s_native_library_class); env->DeleteGlobalRef(s_disk_cache_progress_class); env->DeleteGlobalRef(s_load_callback_stage_class); + env->DeleteGlobalRef(s_game_dir_class); // UnInitialize applets SoftwareKeyboard::CleanupJNI(env); diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h index b76158928..855649efa 100644 --- a/src/android/app/src/main/jni/id_cache.h +++ b/src/android/app/src/main/jni/id_cache.h @@ -13,6 +13,8 @@ JNIEnv* GetEnvForThread(); jclass GetNativeLibraryClass(); jclass GetDiskCacheProgressClass(); jclass GetDiskCacheLoadCallbackStageClass(); +jclass GetGameDirClass(); +jmethodID GetGameDirConstructor(); jmethodID GetExitEmulationActivity(); jmethodID GetDiskCacheLoadProgress(); jmethodID GetOnEmulationStarted(); diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 8e81816e5..763b2164c 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -11,6 +11,7 @@ #include "common/settings.h" #include "frontend_common/config.h" #include "jni/android_common/android_common.h" +#include "jni/id_cache.h" std::unique_ptr config; @@ -253,4 +254,55 @@ jstring Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getPairedSettingKey(JNIEnv* e return ToJString(env, setting->PairedSetting()->GetLabel()); } +jobjectArray Java_org_yuzu_yuzu_1emu_utils_NativeConfig_getGameDirs(JNIEnv* env, jobject obj) { + jclass gameDirClass = IDCache::GetGameDirClass(); + jmethodID gameDirConstructor = IDCache::GetGameDirConstructor(); + jobjectArray jgameDirArray = + env->NewObjectArray(AndroidSettings::values.game_dirs.size(), gameDirClass, nullptr); + for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) { + jobject jgameDir = + env->NewObject(gameDirClass, gameDirConstructor, + ToJString(env, AndroidSettings::values.game_dirs[i].path), + static_cast(AndroidSettings::values.game_dirs[i].deep_scan)); + env->SetObjectArrayElement(jgameDirArray, i, jgameDir); + } + return jgameDirArray; +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_setGameDirs(JNIEnv* env, jobject obj, + jobjectArray gameDirs) { + AndroidSettings::values.game_dirs.clear(); + int size = env->GetArrayLength(gameDirs); + + if (size == 0) { + return; + } + + jobject dir = env->GetObjectArrayElement(gameDirs, 0); + jclass gameDirClass = IDCache::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + for (int i = 0; i < size; ++i) { + dir = env->GetObjectArrayElement(gameDirs, i); + jstring juriString = static_cast(env->GetObjectField(dir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(dir, deepScanBooleanField); + std::string uriString = GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); + } +} + +void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject obj, + jobject gameDir) { + jclass gameDirClass = IDCache::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + + jstring juriString = static_cast(env->GetObjectField(gameDir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(gameDir, deepScanBooleanField); + std::string uriString = GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); +} + } // extern "C" diff --git a/src/android/app/src/main/res/layout/card_folder.xml b/src/android/app/src/main/res/layout/card_folder.xml new file mode 100644 index 000000000..4e0c04b6b --- /dev/null +++ b/src/android/app/src/main/res/layout/card_folder.xml @@ -0,0 +1,70 @@ + + + + + + + + + +