summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt76
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt24
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt30
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt53
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt72
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt128
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsDialogFragment.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt19
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt3
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt33
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt21
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt39
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt20
-rw-r--r--src/android/app/src/main/jni/android_config.cpp50
-rw-r--r--src/android/app/src/main/jni/android_config.h8
-rw-r--r--src/android/app/src/main/jni/android_settings.h8
-rw-r--r--src/android/app/src/main/jni/id_cache.cpp16
-rw-r--r--src/android/app/src/main/jni/id_cache.h2
-rw-r--r--src/android/app/src/main/jni/native_config.cpp52
-rw-r--r--src/android/app/src/main/res/layout/card_folder.xml70
-rw-r--r--src/android/app/src/main/res/layout/dialog_add_folder.xml45
-rw-r--r--src/android/app/src/main/res/layout/dialog_folder_properties.xml30
-rw-r--r--src/android/app/src/main/res/layout/fragment_folders.xml48
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml7
-rw-r--r--src/android/app/src/main/res/values/dimens.xml2
-rw-r--r--src/android/app/src/main/res/values/strings.xml7
-rw-r--r--src/frontend_common/config.cpp2
32 files changed, 848 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 -->
diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp
index 7474cb0f9..1a0491c2c 100644
--- a/src/frontend_common/config.cpp
+++ b/src/frontend_common/config.cpp
@@ -924,12 +924,14 @@ std::string Config::AdjustOutputString(const std::string& string) {
// Windows requires that two forward slashes are used at the start of a path for unmapped
// network drives so we have to watch for that here
+#ifndef ANDROID
if (string.substr(0, 2) == "//") {
boost::replace_all(adjusted_string, "//", "/");
adjusted_string.insert(0, "/");
} else {
boost::replace_all(adjusted_string, "//", "/");
}
+#endif
// Needed for backwards compatibility with QSettings deserialization
for (const auto& special_character : special_characters) {