Browse Source

put external content in "Manage game folders" and show a modal. Also handles logic for android now.

pull/2862/head
Maufeat 2 weeks ago
parent
commit
fd61d098ab
  1. 53
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt
  2. 8
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt
  3. 105
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt
  4. 24
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt
  5. 22
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt
  6. 11
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
  7. 56
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt
  8. 13
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt
  9. 63
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
  10. 20
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  11. 38
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
  12. 45
      src/android/app/src/main/jni/native.cpp
  13. 42
      src/android/app/src/main/res/layout/card_external_content_dir.xml
  14. 17
      src/android/app/src/main/res/layout/card_folder.xml
  15. 69
      src/android/app/src/main/res/layout/fragment_external_content.xml
  16. 11
      src/android/app/src/main/res/navigation/home_navigation.xml
  17. 9
      src/android/app/src/main/res/values/strings.xml

53
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ExternalContentAdapter.kt

@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
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.CardExternalContentDirBinding
import org.yuzu.yuzu_emu.model.ExternalContentViewModel
class ExternalContentAdapter(
private val viewModel: ExternalContentViewModel
) : ListAdapter<String, ExternalContentAdapter.DirectoryViewHolder>(
AsyncDifferConfig.Builder(DiffCallback()).build()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DirectoryViewHolder {
val binding = CardExternalContentDirBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return DirectoryViewHolder(binding)
}
override fun onBindViewHolder(holder: DirectoryViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class DirectoryViewHolder(val binding: CardExternalContentDirBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(path: String) {
binding.textPath.text = path
binding.buttonRemove.setOnClickListener {
viewModel.removeDirectory(path)
}
}
}
private class DiffCallback : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
}
}

8
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt

@ -7,8 +7,10 @@ import android.net.Uri
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.CardFolderBinding import org.yuzu.yuzu_emu.databinding.CardFolderBinding
import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.ViewUtils.marquee import org.yuzu.yuzu_emu.utils.ViewUtils.marquee
@ -31,6 +33,12 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie
path.text = Uri.parse(model.uriString).path path.text = Uri.parse(model.uriString).path
path.marquee() path.marquee()
// Set type indicator, shows below folder name, to see if DLC or Games
typeIndicator.text = when (model.type) {
DirectoryType.GAME -> activity.getString(R.string.games)
DirectoryType.EXTERNAL_CONTENT -> activity.getString(R.string.external_content)
}
buttonEdit.setOnClickListener { buttonEdit.setOnClickListener {
GameFolderPropertiesDialogFragment.newInstance(model) GameFolderPropertiesDialogFragment.newInstance(model)
.show( .show(

105
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ExternalContentFragment.kt

@ -1,105 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.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.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.ExternalContentAdapter
import org.yuzu.yuzu_emu.databinding.FragmentExternalContentBinding
import org.yuzu.yuzu_emu.model.ExternalContentViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins
import org.yuzu.yuzu_emu.utils.collect
class ExternalContentFragment : Fragment() {
private var _binding: FragmentExternalContentBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeViewModel by activityViewModels()
private val externalContentViewModel: ExternalContentViewModel 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)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentExternalContentBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
homeViewModel.setStatusBarShadeVisibility(visible = false)
binding.toolbarExternalContent.setNavigationOnClickListener {
binding.root.findNavController().popBackStack()
}
binding.listExternalDirs.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = ExternalContentAdapter(externalContentViewModel)
}
externalContentViewModel.directories.collect(viewLifecycleOwner) { dirs ->
(binding.listExternalDirs.adapter as ExternalContentAdapter).submitList(dirs)
binding.textEmpty.visibility = if (dirs.isEmpty()) View.VISIBLE else View.GONE
}
val mainActivity = requireActivity() as MainActivity
binding.buttonAdd.setOnClickListener {
mainActivity.getExternalContentDirectory.launch(null)
}
setInsets()
}
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
binding.toolbarExternalContent.updateMargins(left = leftInsets, right = rightInsets)
val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
binding.buttonAdd.updateMargins(
left = leftInsets + fabSpacing,
right = rightInsets + fabSpacing,
bottom = barInsets.bottom + fabSpacing
)
binding.listExternalDirs.updateMargins(left = leftInsets, right = rightInsets)
binding.listExternalDirs.updatePadding(
bottom = barInsets.bottom +
resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
)
windowInsets
}
}

24
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt

@ -6,11 +6,13 @@ package org.yuzu.yuzu_emu.fragments
import android.app.Dialog import android.app.Dialog
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.NativeConfig
@ -25,14 +27,18 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!! val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
// Restore checkbox state
binding.deepScanSwitch.isChecked =
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
// Hide deepScan for external content, do automatically
if (gameDir.type == DirectoryType.EXTERNAL_CONTENT) {
binding.deepScanSwitch.visibility = View.GONE
} else {
// Restore checkbox state for game dirs
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 deepScan = binding.deepScanSwitch.isChecked
binding.deepScanSwitch.setOnClickListener {
deepScan = binding.deepScanSwitch.isChecked
}
} }
return MaterialAlertDialogBuilder(requireContext()) return MaterialAlertDialogBuilder(requireContext())
@ -41,8 +47,10 @@ class GameFolderPropertiesDialogFragment : DialogFragment() {
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
if (folderIndex != -1) { if (folderIndex != -1) {
gamesViewModel.folders.value[folderIndex].deepScan =
binding.deepScanSwitch.isChecked
if (gameDir.type == DirectoryType.GAME) {
gamesViewModel.folders.value[folderIndex].deepScan =
binding.deepScanSwitch.isChecked
}
gamesViewModel.updateGameDirs() gamesViewModel.updateGameDirs()
} }
} }

22
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt

@ -20,6 +20,8 @@ import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.FolderAdapter import org.yuzu.yuzu_emu.adapters.FolderAdapter
import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding
import org.yuzu.yuzu_emu.model.DirectoryType
import org.yuzu.yuzu_emu.model.GameDir
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.ui.main.MainActivity
@ -73,7 +75,25 @@ class GameFoldersFragment : Fragment() {
val mainActivity = requireActivity() as MainActivity val mainActivity = requireActivity() as MainActivity
binding.buttonAdd.setOnClickListener { binding.buttonAdd.setOnClickListener {
mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
// Show a model to choose between Game and External Content
val options = arrayOf(
getString(R.string.games),
getString(R.string.external_content)
)
android.app.AlertDialog.Builder(requireContext())
.setTitle(R.string.add_folders)
.setItems(options) { _, which ->
when (which) {
0 -> { // Game Folder
mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
}
1 -> { // External Content Folder
mainActivity.getExternalContentDirectory.launch(null)
}
}
}
.show()
} }
setInsets() setInsets()

11
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt

@ -179,17 +179,6 @@ class HomeSettingsFragment : Fragment() {
} }
) )
) )
add(
HomeSetting(
R.string.manage_external_content,
R.string.manage_external_content_description,
R.drawable.ic_add,
{
binding.root.findNavController()
.navigate(R.id.action_homeSettingsFragment_to_externalContentFragment)
}
)
)
add( add(
HomeSetting( HomeSetting(
R.string.verify_installed_content, R.string.verify_installed_content,

56
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/ExternalContentViewModel.kt

@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.model
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.utils.NativeConfig
class ExternalContentViewModel : ViewModel() {
private val _directories = MutableStateFlow(listOf<String>())
val directories: StateFlow<List<String>> get() = _directories
init {
loadDirectories()
}
private fun loadDirectories() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
_directories.value = NativeConfig.getExternalContentDirs().toList()
}
}
}
fun addDirectory(dir: DocumentFile) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val path = dir.uri.toString()
val currentDirs = _directories.value.toMutableList()
if (!currentDirs.contains(path)) {
currentDirs.add(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
_directories.value = currentDirs
}
}
}
}
fun removeDirectory(path: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val currentDirs = _directories.value.toMutableList()
currentDirs.remove(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
_directories.value = currentDirs
}
}
}
}

13
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt

@ -9,5 +9,14 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class GameDir( data class GameDir(
val uriString: String, val uriString: String,
var deepScan: Boolean
) : Parcelable
var deepScan: Boolean,
val type: DirectoryType = DirectoryType.GAME
) : Parcelable {
// Needed for JNI backward compatability
constructor(uriString: String, deepScan: Boolean) : this(uriString, deepScan, DirectoryType.GAME)
}
enum class DirectoryType {
GAME,
EXTERNAL_CONTENT
}

63
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt

@ -56,7 +56,7 @@ class GamesViewModel : ViewModel() {
// Ensure keys are loaded so that ROM metadata can be decrypted. // Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys() NativeLibrary.reloadKeys()
getGameDirs()
getGameDirsAndExternalContent()
reloadGames(directoriesChanged = false, firstStartup = true) reloadGames(directoriesChanged = false, firstStartup = true)
} }
@ -144,11 +144,18 @@ class GamesViewModel : ViewModel() {
fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) = fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
NativeConfig.addGameDir(gameDir)
val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true)
getGameDirs(!isFirstTimeSetup)
when (gameDir.type) {
DirectoryType.GAME -> {
NativeConfig.addGameDir(gameDir)
val isFirstTimeSetup = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getBoolean(org.yuzu.yuzu_emu.features.settings.model.Settings.PREF_FIRST_APP_LAUNCH, true)
getGameDirsAndExternalContent(!isFirstTimeSetup)
}
DirectoryType.EXTERNAL_CONTENT -> {
addExternalContentDir(gameDir.uriString)
getGameDirsAndExternalContent()
}
}
} }
if (savedFromGameFragment) { if (savedFromGameFragment) {
@ -168,8 +175,15 @@ class GamesViewModel : ViewModel() {
val removedDirIndex = gameDirs.indexOf(gameDir) val removedDirIndex = gameDirs.indexOf(gameDir)
if (removedDirIndex != -1) { if (removedDirIndex != -1) {
gameDirs.removeAt(removedDirIndex) gameDirs.removeAt(removedDirIndex)
NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirs()
when (gameDir.type) {
DirectoryType.GAME -> {
NativeConfig.setGameDirs(gameDirs.filter { it.type == DirectoryType.GAME }.toTypedArray())
}
DirectoryType.EXTERNAL_CONTENT -> {
removeExternalContentDir(gameDir.uriString)
}
}
getGameDirsAndExternalContent()
} }
} }
} }
@ -177,15 +191,16 @@ class GamesViewModel : ViewModel() {
fun updateGameDirs() = fun updateGameDirs() =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
NativeConfig.setGameDirs(_folders.value.toTypedArray())
getGameDirs()
val gameDirs = _folders.value.filter { it.type == DirectoryType.GAME }
NativeConfig.setGameDirs(gameDirs.toTypedArray())
getGameDirsAndExternalContent()
} }
} }
fun onOpenGameFoldersFragment() = fun onOpenGameFoldersFragment() =
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getGameDirs()
getGameDirsAndExternalContent()
} }
} }
@ -193,16 +208,34 @@ class GamesViewModel : ViewModel() {
NativeConfig.saveGlobalConfig() NativeConfig.saveGlobalConfig()
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
getGameDirs(true)
getGameDirsAndExternalContent(true)
} }
} }
} }
private fun getGameDirs(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs()
_folders.value = gameDirs.toMutableList()
private fun getGameDirsAndExternalContent(reloadList: Boolean = false) {
val gameDirs = NativeConfig.getGameDirs().toMutableList()
val externalContentDirs = NativeConfig.getExternalContentDirs().map {
GameDir(it, false, DirectoryType.EXTERNAL_CONTENT)
}
gameDirs.addAll(externalContentDirs)
_folders.value = gameDirs
if (reloadList) { if (reloadList) {
reloadGames(true) reloadGames(true)
} }
} }
private fun addExternalContentDir(path: String) {
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
if (!currentDirs.contains(path)) {
currentDirs.add(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
}
}
private fun removeExternalContentDir(path: String) {
val currentDirs = NativeConfig.getExternalContentDirs().toMutableList()
currentDirs.remove(path)
NativeConfig.setExternalContentDirs(currentDirs.toTypedArray())
}
} }

20
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt

@ -425,14 +425,18 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
) )
val uriString = result.toString() val uriString = result.toString()
val externalContentViewModel by viewModels<org.yuzu.yuzu_emu.model.ExternalContentViewModel>()
externalContentViewModel.addDirectory(DocumentFile.fromTreeUri(this, result)!!)
Toast.makeText(
applicationContext,
R.string.add_directory_success,
Toast.LENGTH_SHORT
).show()
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
}
val externalContentDir = org.yuzu.yuzu_emu.model.GameDir(uriString, false, org.yuzu.yuzu_emu.model.DirectoryType.EXTERNAL_CONTENT)
gamesViewModel.addFolder(externalContentDir, savedFromGameFragment = false)
} }
val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> val getProdKey = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->

38
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt

@ -49,6 +49,17 @@ object GameHelper {
// Remove previous filesystem provider information so we can get up to date version info // Remove previous filesystem provider information so we can get up to date version info
NativeLibrary.clearFilesystemProvider() NativeLibrary.clearFilesystemProvider()
// Scan External Content directories and register all NSP/XCI files
val externalContentDirs = NativeConfig.getExternalContentDirs()
for (externalDir in externalContentDirs) {
if (externalDir.isNotEmpty()) {
val externalDirUri = externalDir.toUri()
if (FileUtil.isTreeUriValid(externalDirUri)) {
scanExternalContentRecursive(FileUtil.listFiles(externalDirUri), 3)
}
}
}
val badDirs = mutableListOf<Int>() val badDirs = mutableListOf<Int>()
gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
val gameDirUri = gameDir.uriString.toUri() val gameDirUri = gameDir.uriString.toUri()
@ -88,6 +99,33 @@ object GameHelper {
return games.toList() return games.toList()
} }
// File extensions considered as external content, buuut should
// be done better imo.
private val externalContentExtensions = setOf("nsp", "xci")
private fun scanExternalContentRecursive(
files: Array<MinimalDocumentFile>,
depth: Int
) {
if (depth <= 0) {
return
}
files.forEach {
if (it.isDirectory) {
scanExternalContentRecursive(
FileUtil.listFiles(it.uri),
depth - 1
)
} else {
val extension = FileUtil.getExtension(it.uri).lowercase()
if (externalContentExtensions.contains(extension)) {
NativeLibrary.addFileToFilesystemProvider(it.uri.toString())
}
}
}
}
private fun addGamesRecursive( private fun addGamesRecursive(
games: MutableList<Game>, games: MutableList<Game>,
files: Array<MinimalDocumentFile>, files: Array<MinimalDocumentFile>,

45
src/android/app/src/main/jni/native.cpp

@ -210,6 +210,40 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
return; return;
} }
const auto extension = Common::ToLower(filepath.substr(filepath.find_last_of('.') + 1));
if (extension == "nsp") {
auto nsp = std::make_shared<FileSys::NSP>(file);
if (nsp->GetStatus() == Loader::ResultStatus::Success) {
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
LOG_DEBUG(Frontend, "Added NSP entry - TitleID: {:016X}, TitleType: {}, ContentType: {}",
title.first, static_cast<int>(entry.first.first), static_cast<int>(entry.first.second));
}
}
return;
}
}
// Handle XCI files
if (extension == "xci") {
FileSys::XCI xci{file};
if (xci.GetStatus() == Loader::ResultStatus::Success) {
const auto nsp = xci.GetSecurePartitionNSP();
if (nsp) {
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
}
}
}
return;
}
}
auto loader = Loader::GetLoader(m_system, file); auto loader = Loader::GetLoader(m_system, file);
if (!loader) { if (!loader) {
return; return;
@ -226,17 +260,6 @@ void EmulationSession::ConfigureFilesystemProvider(const std::string& filepath)
m_manual_provider->AddEntry(FileSys::TitleType::Application, m_manual_provider->AddEntry(FileSys::TitleType::Application,
FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()),
program_id, file); program_id, file);
} else if (res2 == Loader::ResultStatus::Success &&
(file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) {
const auto nsp = file_type == Loader::FileType::NSP
? std::make_shared<FileSys::NSP>(file)
: FileSys::XCI{file}.GetSecurePartitionNSP();
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
m_manual_provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
}
}
} }
} }

42
src/android/app/src/main/res/layout/card_external_content_dir.xml

@ -1,42 +0,0 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_remove"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/remove_external_content_dir" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

17
src/android/app/src/main/res/layout/card_folder.xml

@ -11,7 +11,7 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"
android:orientation="vertical"
android:padding="16dp" android:padding="16dp"
android:layout_gravity="center_vertical"> android:layout_gravity="center_vertical">
@ -23,12 +23,25 @@
android:layout_gravity="center_vertical|start" android:layout_gravity="center_vertical|start"
android:requiresFadingEdge="horizontal" android:requiresFadingEdge="horizontal"
android:textAlignment="viewStart" android:textAlignment="viewStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/type_indicator"
app:layout_constraintEnd_toStartOf="@+id/button_layout" app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="@string/select_gpu_driver_default" /> tools:text="@string/select_gpu_driver_default" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/type_indicator"
style="@style/TextAppearance.Material3.LabelSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/path"
tools:text="Games" />
<LinearLayout <LinearLayout
android:id="@+id/button_layout" android:id="@+id/button_layout"
android:layout_width="wrap_content" android:layout_width="wrap_content"

69
src/android/app/src/main/res/layout/fragment_external_content.xml

@ -1,69 +0,0 @@
<?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_external_content"
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_external_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:touchscreenBlocksFocus="false"
app:liftOnScrollTargetViewId="@id/list_external_dirs">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar_external_content"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:touchscreenBlocksFocus="false"
app:navigationIcon="@drawable/ic_back"
app:title="@string/external_content_directories" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_external_dirs"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:defaultFocusHighlightEnabled="false" />
<TextView
android:id="@+id/text_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="16dp"
android:text="@string/no_external_content_dirs"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="gone" />
</FrameLayout>
</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_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/add_external_content_dir"
app:srcCompat="@drawable/ic_add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

11
src/android/app/src/main/res/navigation/home_navigation.xml

@ -186,15 +186,4 @@
app:nullable="true" app:nullable="true"
android:defaultValue="@null" /> android:defaultValue="@null" />
</fragment> </fragment>
<fragment
android:id="@+id/externalContentFragment"
android:name="org.yuzu.yuzu_emu.fragments.ExternalContentFragment"
android:label="ExternalContentFragment" />
<action
android:id="@+id/action_global_externalContentFragment"
app:destination="@id/externalContentFragment" />
<action
android:id="@+id/action_homeSettingsFragment_to_externalContentFragment"
app:destination="@id/externalContentFragment" />
</navigation> </navigation>

9
src/android/app/src/main/res/values/strings.xml

@ -1746,12 +1746,7 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string> </string>
<string name="manage_external_content">Manage External Content</string>
<string name="manage_external_content_description">Configure directories for loading DLCs/Updates without NAND installation</string>
<string name="external_content_directories">External Content Directories</string>
<string name="add_external_content_dir">Add Directory</string>
<string name="remove_external_content_dir">Remove</string>
<string name="external_content_description">Add directories containing NSP/XCI files with DLCs and Updates. These will be loaded without installing to NAND, saving disk space.</string>
<string name="no_external_content_dirs">No external content directories configured.\n\nAdd a directory to load DLCs/Updates without NAND installation.</string>
<string name="external_content">External Content</string>
<string name="add_folders">Add Folder</string>
</resources> </resources>
Loading…
Cancel
Save