diff options
Diffstat (limited to 'src/android/app')
31 files changed, 846 insertions, 122 deletions
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<GameDir, FolderAdapter.FolderViewHolder>( + 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<GameDir>() { + 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/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt index d005c656e..e3cd66185 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt @@ -3,33 +3,9 @@ package org.yuzu.yuzu_emu.features.settings.model -import android.text.TextUtils -import android.widget.Toast import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.utils.NativeConfig object Settings { - private val context get() = YuzuApplication.appContext - - fun saveSettings(gameId: String = "") { - if (TextUtils.isEmpty(gameId)) { - Toast.makeText( - context, - context.getString(R.string.ini_saved), - Toast.LENGTH_SHORT - ).show() - NativeConfig.saveSettings() - } else { - // TODO: Save custom game settings - Toast.makeText( - context, - context.getString(R.string.gameid_saved, gameId), - Toast.LENGTH_SHORT - ).show() - } - } - enum class Category { Android, Audio, 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 48bdbdd75..64bfc6dd0 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 @@ -19,12 +19,13 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.NavHostFragment import androidx.navigation.navArgs import com.google.android.material.color.MaterialColors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.IOException import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding -import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment import org.yuzu.yuzu_emu.model.SettingsViewModel @@ -53,10 +54,6 @@ class SettingsActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - if (savedInstanceState != null) { - settingsViewModel.shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE) - } - if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION ) { @@ -127,12 +124,6 @@ class SettingsActivity : AppCompatActivity() { } } - override fun onSaveInstanceState(outState: Bundle) { - // Critical: If super method is not called, rotations will be busted. - super.onSaveInstanceState(outState) - outState.putBoolean(KEY_SHOULD_SAVE, settingsViewModel.shouldSave) - } - override fun onStart() { super.onStart() // TODO: Load custom settings contextually @@ -141,16 +132,10 @@ class SettingsActivity : AppCompatActivity() { } } - /** - * If this is called, the user has left the settings screen (potentially through the - * home button) and will expect their changes to be persisted. So we kick off an - * IntentService which will do so on a background thread. - */ override fun onStop() { super.onStop() - if (isFinishing && settingsViewModel.shouldSave) { - Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...") - Settings.saveSettings() + CoroutineScope(Dispatchers.IO).launch { + NativeConfig.saveSettings() } } @@ -160,9 +145,6 @@ class SettingsActivity : AppCompatActivity() { } fun onSettingsReset() { - // Prevents saving to a non-existent settings file - settingsViewModel.shouldSave = false - // Delete settings file because the user may have changed values that do not exist in the UI NativeConfig.unloadConfig() val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) @@ -194,8 +176,4 @@ class SettingsActivity : AppCompatActivity() { windowInsets } } - - companion object { - private const val KEY_SHOULD_SAVE = "should_save" - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt index a7a029fc1..af2c1e582 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -105,7 +105,6 @@ class SettingsAdapter( fun onBooleanClick(item: SwitchSetting, checked: Boolean) { item.checked = checked settingsViewModel.setShouldReloadSettingsList(true) - settingsViewModel.shouldSave = true } fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { @@ -161,7 +160,6 @@ class SettingsAdapter( epochTime += timePicker.hour.toLong() * 60 * 60 epochTime += timePicker.minute.toLong() * 60 if (item.value != epochTime) { - settingsViewModel.shouldSave = true notifyItemChanged(position) item.value = epochTime } 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<GameDir>(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/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt index d18ec6974..b88d2c038 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt @@ -52,7 +52,6 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> settingsViewModel.clickedItem!!.setting.reset() settingsViewModel.setAdapterItemChanged(position) - settingsViewModel.shouldSave = true } .setNegativeButton(android.R.string.cancel, null) .create() @@ -137,24 +136,17 @@ class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener is SingleChoiceSetting -> { val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting val value = getValueForSingleChoiceSelection(scSetting, which) - if (scSetting.selectedValue != value) { - settingsViewModel.shouldSave = true - } scSetting.selectedValue = value } is StringSingleChoiceSetting -> { val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting val value = scSetting.getValueAt(which) - if (scSetting.selectedValue != value) settingsViewModel.shouldSave = true scSetting.selectedValue = value } is SliderSetting -> { val sliderSetting = settingsViewModel.clickedItem as SliderSetting - if (sliderSetting.selectedValue != settingsViewModel.sliderProgress.value) { - settingsViewModel.shouldSave = true - } sliderSetting.selectedValue = settingsViewModel.sliderProgress.value } } 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<List<Game>> get() = _games @@ -40,6 +42,9 @@ class GamesViewModel : ViewModel() { val searchFocused: StateFlow<Boolean> get() = _searchFocused private val _searchFocused = MutableStateFlow(false) + private val _folders = MutableStateFlow(mutableListOf<GameDir>()) + 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<Game>() 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<Pair<Boolean, Boolean>> get() = _navigationVisible @@ -23,14 +17,6 @@ class HomeViewModel : ViewModel() { val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward private val _shouldPageForward = MutableStateFlow(false) - val gamesDir: StateFlow<String> 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/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt index 6f947674e..ccc981e95 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt @@ -13,8 +13,6 @@ import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem class SettingsViewModel : ViewModel() { var game: Game? = null - var shouldSave = false - var clickedItem: SettingsItem? = null val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate @@ -73,6 +71,5 @@ class SettingsViewModel : ViewModel() { fun clear() { game = null - shouldSave = false } } 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..16323a316 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 @@ -252,6 +253,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { super.onResume() } + override fun onStop() { + super.onStop() + CoroutineScope(Dispatchers.IO).launch { + NativeConfig.saveSettings() + } + } + override fun onDestroy() { EmulationActivity.stopForegroundService(this) super.onDestroy() @@ -293,20 +301,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() - - Toast.makeText( - applicationContext, - R.string.games_dir_selected, - Toast.LENGTH_LONG - ).show() + 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 + } - 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<Game> { val games = mutableListOf<Game>() 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<GameDir>() + 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<Int>() + 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<String>() 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<GameDir> + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array<GameDir>) + + /** + * 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<Settings::BasicSetting*>& 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<GameDir> game_dirs; + // Android Settings::Setting<bool> 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<jclass>(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<jclass>(env->NewGlobalRef(game_dir_class)); + s_game_dir_constructor = env->GetMethodID(game_dir_class, "<init>", "(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<AndroidConfig> 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<jboolean>(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<jstring>(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<bool>(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<jstring>(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<bool>(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 @@ +<?xml version="1.0" encoding="utf-8"?> +<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?attr/materialCardViewOutlinedStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + android:layout_marginVertical="12dp" + android:focusable="true"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + android:layout_gravity="center_vertical" + android:animateLayoutChanges="true"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/path" + style="@style/TextAppearance.Material3.BodyLarge" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical|start" + android:ellipsize="none" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/button_layout" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="@string/select_gpu_driver_default" /> + + <LinearLayout + android:id="@+id/button_layout" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <Button + android:id="@+id/button_edit" + style="@style/Widget.Material3.Button.IconButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@string/delete" + android:tooltipText="@string/edit" + app:icon="@drawable/ic_edit" + app:iconTint="?attr/colorControlNormal" /> + + <Button + android:id="@+id/button_delete" + style="@style/Widget.Material3.Button.IconButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@string/delete" + android:tooltipText="@string/delete" + app:icon="@drawable/ic_delete" + app:iconTint="?attr/colorControlNormal" /> + + </LinearLayout> + + </androidx.constraintlayout.widget.ConstraintLayout> + +</com.google.android.material.card.MaterialCardView> diff --git a/src/android/app/src/main/res/layout/dialog_add_folder.xml b/src/android/app/src/main/res/layout/dialog_add_folder.xml new file mode 100644 index 000000000..01f95e868 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_add_folder.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="24dp" + android:orientation="vertical"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/path" + style="@style/TextAppearance.Material3.BodyLarge" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_gravity="center_vertical|start" + android:layout_weight="1" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:requiresFadingEdge="horizontal" + android:singleLine="true" + android:textAlignment="viewStart" + tools:text="folder/folder/folder/folder" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingTop="8dp"> + + <com.google.android.material.textview.MaterialTextView + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical|start" + android:layout_weight="1" + android:text="@string/deep_scan" + android:textAlignment="viewStart" /> + + <com.google.android.material.checkbox.MaterialCheckBox + android:id="@+id/deep_scan_switch" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + +</LinearLayout> diff --git a/src/android/app/src/main/res/layout/dialog_folder_properties.xml b/src/android/app/src/main/res/layout/dialog_folder_properties.xml new file mode 100644 index 000000000..248d048cb --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_folder_properties.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="24dp" + android:orientation="vertical"> + + <LinearLayout + android:id="@+id/deep_scan_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <com.google.android.material.textview.MaterialTextView + style="@style/TextAppearance.Material3.BodyMedium" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical|start" + android:layout_weight="1" + android:text="@string/deep_scan" + android:textAlignment="viewStart" /> + + <com.google.android.material.checkbox.MaterialCheckBox + android:id="@+id/deep_scan_switch" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + +</LinearLayout> diff --git a/src/android/app/src/main/res/layout/fragment_folders.xml b/src/android/app/src/main/res/layout/fragment_folders.xml new file mode 100644 index 000000000..74f2f3754 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_folders.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/coordinator_folders" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/colorSurface"> + + <androidx.coordinatorlayout.widget.CoordinatorLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/appbar_folders" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fitsSystemWindows="true" + app:liftOnScrollTargetViewId="@id/list_folders"> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/toolbar_folders" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:title="@string/game_folders" /> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/list_folders" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + + </androidx.coordinatorlayout.widget.CoordinatorLayout> + + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/button_add" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|end" + android:contentDescription="@string/add_games" + app:srcCompat="@drawable/ic_add" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml index 6d4c1f86d..cf70b4bc4 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -28,6 +28,9 @@ <action android:id="@+id/action_homeSettingsFragment_to_appletLauncherFragment" app:destination="@id/appletLauncherFragment" /> + <action + android:id="@+id/action_homeSettingsFragment_to_gameFoldersFragment" + app:destination="@id/gameFoldersFragment" /> </fragment> <fragment @@ -117,5 +120,9 @@ android:id="@+id/cabinetLauncherDialogFragment" android:name="org.yuzu.yuzu_emu.fragments.CabinetLauncherDialogFragment" android:label="CabinetLauncherDialogFragment" /> + <fragment + android:id="@+id/gameFoldersFragment" + android:name="org.yuzu.yuzu_emu.fragments.GameFoldersFragment" + android:label="GameFoldersFragment" /> </navigation> diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index ef855ea6f..380d14213 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -13,7 +13,7 @@ <dimen name="menu_width">256dp</dimen> <dimen name="card_width">165dp</dimen> <dimen name="icon_inset">24dp</dimen> - <dimen name="spacing_bottom_list_fab">72dp</dimen> + <dimen name="spacing_bottom_list_fab">76dp</dimen> <dimen name="spacing_fab">24dp</dimen> <dimen name="dialog_margin">20dp</dimen> diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 471af8795..fa9b153b6 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> <string name="search_and_filter_games">Search and filter games</string> <string name="select_games_folder">Select games folder</string> + <string name="manage_game_folders">Manage game folders</string> <string name="select_games_folder_description">Allows yuzu to populate the games list</string> <string name="add_games_warning">Skip selecting games folder?</string> <string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string> @@ -124,6 +125,11 @@ <string name="manage_yuzu_data_description">Import/export firmware, keys, user data, and more!</string> <string name="share_save_file">Share save file</string> <string name="export_save_failed">Failed to export save</string> + <string name="game_folders">Game folders</string> + <string name="deep_scan">Deep scan</string> + <string name="add_game_folder">Add game folder</string> + <string name="folder_already_added">This folder was already added!</string> + <string name="game_folder_properties">Game folder properties</string> <!-- Applet launcher strings --> <string name="applets">Applet launcher</string> @@ -257,6 +263,7 @@ <string name="cancelling">Cancelling</string> <string name="install">Install</string> <string name="delete">Delete</string> + <string name="edit">Edit</string> <string name="export_success">Exported successfully</string> <!-- GPU driver installation --> |