diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index c642dbdcda..b899ca07fa 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -73,6 +73,11 @@ SPDX-License-Identifier: GPL-3.0-or-later android:theme="@style/Theme.Yuzu.Main" android:label="@string/preferences_settings"/> + + ) : title = YuzuApplication.appContext.getString(applet.titleId), path = appletPath ) - val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) - binding.root.findNavController().navigate(action) + binding.root.findNavController().navigate( + R.id.action_global_emulationActivity, + bundleOf( + "game" to appletGame, + "custom" to false + ) + ) } } } 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 11be703536..d33bbc3d7d 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 @@ -111,18 +111,10 @@ class SettingsActivity : AppCompatActivity() { if (navHostFragment.childFragmentManager.backStackEntryCount > 0) { navHostFragment.navController.popBackStack() } else { - finishWithFragmentLikeAnimation() + finish() } } - private fun finishWithFragmentLikeAnimation() { - finish() - overridePendingTransition( - androidx.navigation.ui.R.anim.nav_default_pop_enter_anim, - androidx.navigation.ui.R.anim.nav_default_pop_exit_anim - ) - } - override fun onStart() { super.onStart() if (!DirectoryInitialization.areDirectoriesReady) { @@ -178,7 +170,7 @@ class SettingsActivity : AppCompatActivity() { getString(R.string.settings_reset), Toast.LENGTH_LONG ).show() - finishWithFragmentLikeAnimation() + finish() } private fun setInsets() { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSubscreenActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSubscreenActivity.kt new file mode 100644 index 0000000000..11ecd355fb --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSubscreenActivity.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.navArgs +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.InsetsHelper +import org.yuzu.yuzu_emu.utils.ThemeHelper + +enum class SettingsSubscreen { + PROFILE_MANAGER, + DRIVER_MANAGER, + DRIVER_FETCHER, + FREEDRENO_SETTINGS, + APPLET_LAUNCHER, + INSTALLABLE, + GAME_FOLDERS, + ABOUT, + LICENSES, + GAME_INFO, + ADDONS, +} + +class SettingsSubscreenActivity : AppCompatActivity() { + private lateinit var binding: ActivitySettingsBinding + + private val args by navArgs() + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(YuzuApplication.applyLanguage(base)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + if (savedInstanceState == null) { + val navController = navHostFragment.navController + val navGraph = navController.navInflater.inflate( + R.navigation.settings_subscreen_navigation + ) + navGraph.setStartDestination(resolveStartDestination()) + navController.setGraph(navGraph, createStartDestinationArgs()) + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.navigationBarShade, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + } + ) + + setInsets() + } + + override fun onStart() { + super.onStart() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + } + + fun navigateBack() { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + if (!navHostFragment.navController.popBackStack()) { + finish() + } + } + + private fun resolveStartDestination(): Int = + when (args.destination) { + SettingsSubscreen.PROFILE_MANAGER -> R.id.profileManagerFragment + SettingsSubscreen.DRIVER_MANAGER -> R.id.driverManagerFragment + SettingsSubscreen.DRIVER_FETCHER -> R.id.driverFetcherFragment + SettingsSubscreen.FREEDRENO_SETTINGS -> R.id.freedrenoSettingsFragment + SettingsSubscreen.APPLET_LAUNCHER -> R.id.appletLauncherFragment + SettingsSubscreen.INSTALLABLE -> R.id.installableFragment + SettingsSubscreen.GAME_FOLDERS -> R.id.gameFoldersFragment + SettingsSubscreen.ABOUT -> R.id.aboutFragment + SettingsSubscreen.LICENSES -> R.id.licensesFragment + SettingsSubscreen.GAME_INFO -> R.id.gameInfoFragment + SettingsSubscreen.ADDONS -> R.id.addonsFragment + } + + private fun createStartDestinationArgs(): Bundle = + when (args.destination) { + SettingsSubscreen.DRIVER_MANAGER, + SettingsSubscreen.FREEDRENO_SETTINGS -> bundleOf("game" to args.game) + + SettingsSubscreen.GAME_INFO, + SettingsSubscreen.ADDONS -> bundleOf( + "game" to requireNotNull(args.game) { + "Game is required for ${args.destination}" + } + ) + + else -> Bundle() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.navigationBarShade + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt index 7fec413b66..aa2b3c7df2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt @@ -21,9 +21,10 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.HomeNavigationDirections import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding +import org.yuzu.yuzu_emu.features.settings.ui.SettingsSubscreen import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins import org.yuzu.yuzu_emu.NativeLibrary @@ -54,7 +55,7 @@ class AboutFragment : Fragment() { super.onViewCreated(view, savedInstanceState) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarAbout.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } binding.imageLogo.setOnLongClickListener { @@ -72,8 +73,11 @@ class AboutFragment : Fragment() { ) } binding.buttonLicenses.setOnClickListener { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.LICENSES, + null + ) + binding.root.findNavController().navigate(action) } val buildName = getString(R.string.app_name_suffixed) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt index 96b7a8cce2..b20d75ef0a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt @@ -15,7 +15,6 @@ import androidx.core.view.updatePadding import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.transition.MaterialSharedAxis @@ -61,7 +60,7 @@ class AddonsFragment : Fragment() { homeViewModel.setStatusBarShadeVisibility(false) binding.toolbarAddons.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt index 3ab171a8d4..49237cb756 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -12,7 +12,6 @@ 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.GridLayoutManager import com.google.android.material.transition.MaterialSharedAxis import org.yuzu.yuzu_emu.R @@ -50,7 +49,7 @@ class AppletLauncherFragment : Fragment() { homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarApplets.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } val applets = listOf( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverFetcherFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverFetcherFragment.kt index e2b652dc60..eacc64a63e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverFetcherFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverFetcherFragment.kt @@ -13,7 +13,6 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -142,7 +141,7 @@ class DriverFetcherFragment : Fragment() { super.onViewCreated(view, savedInstanceState) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarDrivers.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } binding.listDrivers.layoutManager = LinearLayoutManager(context) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt index 23334f05eb..89a6362dc6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -19,6 +19,7 @@ import androidx.navigation.fragment.navArgs import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.HomeNavigationDirections import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -27,6 +28,7 @@ import org.yuzu.yuzu_emu.adapters.DriverAdapter import org.yuzu.yuzu_emu.databinding.FragmentDriverManagerBinding import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsSubscreen import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.HomeViewModel @@ -105,7 +107,7 @@ class DriverManagerFragment : Fragment() { } binding.toolbarDrivers.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } binding.buttonInstall.setOnClickListener { @@ -113,9 +115,11 @@ class DriverManagerFragment : Fragment() { } binding.buttonFetch.setOnClickListener { - binding.root.findNavController().navigate( - R.id.action_driverManagerFragment_to_driverFetcherFragment + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.DRIVER_FETCHER, + null ) + binding.root.findNavController().navigate(action) } binding.listDrivers.apply { 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 index 9c43d2c6e1..6e05df799b 100644 --- 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 @@ -4,20 +4,21 @@ package org.yuzu.yuzu_emu.fragments import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.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.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder 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 @@ -25,7 +26,6 @@ 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.HomeViewModel -import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins import org.yuzu.yuzu_emu.utils.collect @@ -36,6 +36,20 @@ class GameFoldersFragment : Fragment() { private val homeViewModel: HomeViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels() + private val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processGamesDir(result) + } + } + + private val getExternalContentDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processExternalContentDir(result) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) @@ -59,7 +73,7 @@ class GameFoldersFragment : Fragment() { homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarFolders.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } binding.listFolders.apply { @@ -74,7 +88,6 @@ class GameFoldersFragment : Fragment() { (binding.listFolders.adapter as FolderAdapter).submitList(it) } - val mainActivity = requireActivity() as MainActivity binding.buttonAdd.setOnClickListener { // Show a model to choose between Game and External Content val options = arrayOf( @@ -87,10 +100,10 @@ class GameFoldersFragment : Fragment() { .setItems(options) { _, which -> when (which) { 0 -> { // Game Folder - mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) } 1 -> { // External Content Folder - mainActivity.getExternalContentDirectory.launch(null) + getExternalContentDirectory.launch(null) } } } @@ -105,6 +118,50 @@ class GameFoldersFragment : Fragment() { gamesViewModel.onCloseGameFoldersFragment() } + private fun processGamesDir(result: Uri) { + requireContext().contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + requireContext().applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + AddGameFolderDialogFragment.newInstance(uriString, calledFromGameFragment = false) + .show(parentFragmentManager, AddGameFolderDialogFragment.TAG) + } + + private fun processExternalContentDir(result: Uri) { + requireContext().contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { + it.uriString == uriString && it.type == DirectoryType.EXTERNAL_CONTENT + } + if (folder != null) { + Toast.makeText( + requireContext().applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + val externalContentDir = GameDir(uriString, deepScan = false, DirectoryType.EXTERNAL_CONTENT) + gamesViewModel.addFolder(externalContentDir, savedFromGameFragment = false) + } + private fun setInsets() = ViewCompat.setOnApplyWindowInsetsListener( binding.root diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt index 7863e40ff5..5d6238e5a1 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -18,7 +18,6 @@ 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.navigation.fragment.navArgs import com.google.android.material.transition.MaterialSharedAxis import org.yuzu.yuzu_emu.NativeLibrary @@ -64,7 +63,7 @@ class GameInfoFragment : Fragment() { binding.apply { toolbarInfo.title = args.game.title toolbarInfo.setNavigationOnClickListener { - view.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } val pathString = Uri.parse(args.game.path).path ?: "" diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index 9e55297846..46b75197d5 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -35,6 +35,7 @@ import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsSubscreen import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.GameProperty import org.yuzu.yuzu_emu.model.GamesViewModel @@ -250,8 +251,10 @@ class GamePropertiesFragment : Fragment() { R.string.info_description, R.drawable.ic_info_outline, action = { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.GAME_INFO, + args.game + ) binding.root.findNavController().navigate(action) } ) @@ -317,8 +320,11 @@ class GamePropertiesFragment : Fragment() { R.string.add_ons_description, R.drawable.ic_edit, action = { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToAddonsFragment(args.game) + val action = + HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.ADDONS, + args.game + ) binding.root.findNavController().navigate(action) } ) @@ -333,8 +339,11 @@ class GamePropertiesFragment : Fragment() { R.drawable.ic_build, detailsFlow = driverViewModel.selectedDriverTitle, action = { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) + val action = + HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.DRIVER_MANAGER, + args.game + ) binding.root.findNavController().navigate(action) } ) @@ -347,8 +356,11 @@ class GamePropertiesFragment : Fragment() { R.string.freedreno_per_game_description, R.drawable.ic_graphics, action = { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToFreedrenoSettingsFragment(args.game) + val action = + HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.FREEDRENO_SETTINGS, + args.game + ) binding.root.findNavController().navigate(action) } ) 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 6f4bf858ea..37eda22c69 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 @@ -36,6 +36,7 @@ import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.fetcher.SpacingItemDecoration import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsSubscreen import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.HomeSetting import org.yuzu.yuzu_emu.model.HomeViewModel @@ -126,8 +127,11 @@ class HomeSettingsFragment : Fragment() { R.string.profile_manager_description, R.drawable.ic_account_circle, { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_profileManagerFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.PROFILE_MANAGER, + null + ) + binding.root.findNavController().navigate(action) } ) ) @@ -137,8 +141,10 @@ class HomeSettingsFragment : Fragment() { R.string.install_gpu_driver_description, R.drawable.ic_build, { - val action = HomeSettingsFragmentDirections - .actionHomeSettingsFragmentToDriverManagerFragment(null) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.DRIVER_MANAGER, + null + ) binding.root.findNavController().navigate(action) }, { true }, @@ -154,7 +160,12 @@ class HomeSettingsFragment : Fragment() { R.string.gpu_driver_settings, R.drawable.ic_graphics, { - binding.root.findNavController().navigate(R.id.freedrenoSettingsFragment) + val action = + HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.FREEDRENO_SETTINGS, + null + ) + binding.root.findNavController().navigate(action) } ) ) @@ -175,8 +186,11 @@ class HomeSettingsFragment : Fragment() { R.string.applets_description, R.drawable.ic_applet, { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_appletLauncherFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.APPLET_LAUNCHER, + null + ) + binding.root.findNavController().navigate(action) }, { NativeLibrary.isFirmwareAvailable() }, R.string.applets_error_firmware, @@ -189,8 +203,11 @@ class HomeSettingsFragment : Fragment() { R.string.manage_yuzu_data_description, R.drawable.ic_install, { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_installableFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.INSTALLABLE, + null + ) + binding.root.findNavController().navigate(action) } ) ) @@ -200,8 +217,11 @@ class HomeSettingsFragment : Fragment() { R.string.select_games_folder_description, R.drawable.ic_add, { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.GAME_FOLDERS, + null + ) + binding.root.findNavController().navigate(action) } ) ) @@ -284,9 +304,11 @@ class HomeSettingsFragment : Fragment() { R.string.about_description, R.drawable.ic_info_outline, { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - parentFragmentManager.primaryNavigationFragment?.findNavController() - ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) + val action = HomeNavigationDirections.actionGlobalSettingsSubscreenActivity( + SettingsSubscreen.ABOUT, + null + ) + binding.root.findNavController().navigate(action) } ) ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt index 1b94d5f1a6..10862c37b4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -14,23 +14,23 @@ 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.GridLayoutManager import com.google.android.material.transition.MaterialSharedAxis import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.adapters.InstallableAdapter import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.Installable import org.yuzu.yuzu_emu.model.TaskState -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.InstallableActions import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins import org.yuzu.yuzu_emu.utils.collect @@ -45,6 +45,9 @@ class InstallableFragment : Fragment() { private val binding get() = _binding!! private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -65,12 +68,10 @@ class InstallableFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val mainActivity = requireActivity() as MainActivity - homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarInstallables.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } homeViewModel.openImportSaves.collect(viewLifecycleOwner) { @@ -84,8 +85,8 @@ class InstallableFragment : Fragment() { Installable( R.string.user_data, R.string.user_data_description, - install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, - export = { mainActivity.exportUserData.launch("export.zip") } + install = { importUserDataLauncher.launch(arrayOf("application/zip")) }, + export = { exportUserDataLauncher.launch("export.zip") } ), Installable( R.string.manage_save_data, @@ -127,27 +128,33 @@ class InstallableFragment : Fragment() { Installable( R.string.install_game_content, R.string.install_game_content_description, - install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } + install = { installGameUpdateLauncher.launch(arrayOf("*/*")) } ), Installable( R.string.install_firmware, R.string.install_firmware_description, - install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) } + install = { getFirmwareLauncher.launch(arrayOf("application/zip")) } ), Installable( R.string.uninstall_firmware, R.string.uninstall_firmware_description, - install = { mainActivity.uninstallFirmware() } + install = { + InstallableActions.uninstallFirmware( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + homeViewModel = homeViewModel + ) + } ), Installable( R.string.install_prod_keys, R.string.install_prod_keys_description, - install = { mainActivity.getProdKey.launch(arrayOf("*/*")) } + install = { getProdKeyLauncher.launch(arrayOf("*/*")) } ), Installable( R.string.install_amiibo_keys, R.string.install_amiibo_keys_description, - install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } + install = { getAmiiboKeyLauncher.launch(arrayOf("*/*")) } ) ) @@ -180,6 +187,132 @@ class InstallableFragment : Fragment() { windowInsets } + private val getProdKeyLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + InstallableActions.processKey( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + gamesViewModel = gamesViewModel, + result = result, + extension = "keys" + ) + } + } + + private val getAmiiboKeyLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + InstallableActions.processKey( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + gamesViewModel = gamesViewModel, + result = result, + extension = "bin" + ) + } + } + + private val getFirmwareLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + InstallableActions.processFirmware( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + homeViewModel = homeViewModel, + result = result + ) + } + } + + private val installGameUpdateLauncher = + registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { documents -> + if (documents.isEmpty()) { + return@registerForActivityResult + } + + if (addonViewModel.game == null) { + InstallableActions.installContent( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + addonViewModel = addonViewModel, + documents = documents + ) + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.verifying_content, + false + ) { _, _ -> + var updatesMatchProgram = true + for (document in documents) { + val valid = NativeLibrary.doesUpdateMatchProgram( + addonViewModel.game!!.programId, + document.toString() + ) + if (!valid) { + updatesMatchProgram = false + break + } + } + + if (updatesMatchProgram) { + requireActivity().runOnUiThread { + InstallableActions.installContent( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + addonViewModel = addonViewModel, + documents = documents + ) + } + } else { + requireActivity().runOnUiThread { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.content_install_notice, + descriptionId = R.string.content_install_notice_description, + positiveAction = { + InstallableActions.installContent( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + addonViewModel = addonViewModel, + documents = documents + ) + }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + return@newInstance Any() + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private val importUserDataLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + InstallableActions.importUserData( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + gamesViewModel = gamesViewModel, + driverViewModel = driverViewModel, + result = result + ) + } + } + + private val exportUserDataLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { result -> + if (result != null) { + InstallableActions.exportUserData( + activity = requireActivity(), + fragmentManager = parentFragmentManager, + result = result + ) + } + } + private val importSaves = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> if (result == null) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt index aa18aa2482..32b72fe38f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -13,7 +13,6 @@ 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 @@ -48,7 +47,7 @@ class LicensesFragment : Fragment() { homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarLicenses.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } val licenses = listOf( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt index 6ee34105e7..2786906f6b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt @@ -51,7 +51,7 @@ class ProfileManagerFragment : Fragment() { homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarProfiles.setNavigationOnClickListener { - findNavController().popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() } setupRecyclerView() 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 74a171cf1f..f0806df786 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 @@ -26,7 +26,6 @@ import androidx.preference.PreferenceManager import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File -import java.io.FilenameFilter import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivityMainBinding @@ -39,16 +38,10 @@ import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.InstallResult import android.os.Build -import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.model.TaskViewModel import org.yuzu.yuzu_emu.utils.* import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream import androidx.core.content.edit import org.yuzu.yuzu_emu.activities.EmulationActivity import kotlin.text.compareTo @@ -453,35 +446,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } fun processKey(result: Uri, extension: String = "keys") { - contentResolver.takePersistableUriPermission( - result, - Intent.FLAG_GRANT_READ_URI_PERMISSION + InstallableActions.processKey( + activity = this, + fragmentManager = supportFragmentManager, + gamesViewModel = gamesViewModel, + result = result, + extension = extension ) - - val resultCode: Int = NativeLibrary.installKeys(result.toString(), extension) - - if (resultCode == 0) { - // TODO(crueter): It may be worth it to switch some of these Toasts to snackbars, - // since most of it is foreground-only anyways. - Toast.makeText( - applicationContext, - R.string.keys_install_success, - Toast.LENGTH_SHORT - ).show() - - gamesViewModel.reloadGames(true) - - return - } - - val resultString: String = - resources.getStringArray(R.array.installKeysResults)[resultCode] - - MessageDialogFragment.newInstance( - titleId = R.string.keys_failed, - descriptionString = resultString, - helpLinkId = R.string.keys_missing_help - ).show(supportFragmentManager, MessageDialogFragment.TAG) } val getFirmware = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> @@ -491,75 +462,21 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } fun processFirmware(result: Uri, onComplete: (() -> Unit)? = null) { - val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } - - val firmwarePath = - File(NativeConfig.getNandDir() + "/system/Contents/registered/") - val cacheFirmwareDir = File("${cacheDir.path}/registered/") - - ProgressDialogFragment.newInstance( - this, - R.string.firmware_installing - ) { progressCallback, _ -> - var messageToShow: Any - try { - FileUtil.unzipToInternalStorage( - result.toString(), - cacheFirmwareDir, - progressCallback - ) - val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 - val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 - messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { - MessageDialogFragment.newInstance( - this, - titleId = R.string.firmware_installed_failure, - descriptionId = R.string.firmware_installed_failure_description - ) - } else { - firmwarePath.deleteRecursively() - cacheFirmwareDir.copyRecursively(firmwarePath, true) - NativeLibrary.initializeSystem(true) - homeViewModel.setCheckKeys(true) - getString(R.string.save_file_imported_success) - } - } catch (e: Exception) { - Log.error("[MainActivity] Firmware install failed - ${e.message}") - messageToShow = getString(R.string.fatal_error) - } finally { - cacheFirmwareDir.deleteRecursively() - } - messageToShow - }.apply { - onDialogComplete = onComplete - }.show(supportFragmentManager, ProgressDialogFragment.TAG) + InstallableActions.processFirmware( + activity = this, + fragmentManager = supportFragmentManager, + homeViewModel = homeViewModel, + result = result, + onComplete = onComplete + ) } fun uninstallFirmware() { - val firmwarePath = - File(NativeConfig.getNandDir() + "/system/Contents/registered/") - ProgressDialogFragment.newInstance( - this, - R.string.firmware_uninstalling - ) { progressCallback, _ -> - var messageToShow: Any - try { - // Ensure the firmware directory exists before attempting to delete - if (firmwarePath.exists()) { - firmwarePath.deleteRecursively() - // Optionally reinitialize the system or perform other necessary steps - NativeLibrary.initializeSystem(true) - homeViewModel.setCheckKeys(true) - messageToShow = getString(R.string.firmware_uninstalled_success) - } else { - messageToShow = getString(R.string.firmware_uninstalled_failure) - } - } catch (e: Exception) { - Log.error("[MainActivity] Firmware uninstall failed - ${e.message}") - messageToShow = getString(R.string.fatal_error) - } - messageToShow - }.show(supportFragmentManager, ProgressDialogFragment.TAG) + InstallableActions.uninstallFirmware( + activity = this, + fragmentManager = supportFragmentManager, + homeViewModel = homeViewModel + ) } val installGameUpdate = registerForActivityResult( @@ -606,101 +523,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } private fun installContent(documents: List) { - ProgressDialogFragment.newInstance( - this@MainActivity, - R.string.installing_game_content - ) { progressCallback, messageCallback -> - var installSuccess = 0 - var installOverwrite = 0 - var errorBaseGame = 0 - var error = 0 - documents.forEach { - messageCallback.invoke(FileUtil.getFilename(it)) - when ( - InstallResult.from( - NativeLibrary.installFileToNand( - it.toString(), - progressCallback - ) - ) - ) { - InstallResult.Success -> { - installSuccess += 1 - } - - InstallResult.Overwrite -> { - installOverwrite += 1 - } - - InstallResult.BaseInstallAttempted -> { - errorBaseGame += 1 - } - - InstallResult.Failure -> { - error += 1 - } - } - } - - addonViewModel.refreshAddons(force = true) - - val separator = System.lineSeparator() ?: "\n" - val installResult = StringBuilder() - if (installSuccess > 0) { - installResult.append( - getString( - R.string.install_game_content_success_install, - installSuccess - ) - ) - installResult.append(separator) - } - if (installOverwrite > 0) { - installResult.append( - getString( - R.string.install_game_content_success_overwrite, - installOverwrite - ) - ) - installResult.append(separator) - } - val errorTotal: Int = errorBaseGame + error - if (errorTotal > 0) { - installResult.append(separator) - installResult.append( - getString( - R.string.install_game_content_failed_count, - errorTotal - ) - ) - installResult.append(separator) - if (errorBaseGame > 0) { - installResult.append(separator) - installResult.append( - getString(R.string.install_game_content_failure_base) - ) - installResult.append(separator) - } - if (error > 0) { - installResult.append( - getString(R.string.install_game_content_failure_description) - ) - installResult.append(separator) - } - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_failure, - descriptionString = installResult.toString().trim(), - helpLinkId = R.string.install_game_content_help_link - ) - } else { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_success, - descriptionString = installResult.toString().trim() - ) - } - }.show(supportFragmentManager, ProgressDialogFragment.TAG) + InstallableActions.installContent( + activity = this, + fragmentManager = supportFragmentManager, + addonViewModel = addonViewModel, + documents = documents + ) } val exportUserData = registerForActivityResult( @@ -709,25 +537,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (result == null) { return@registerForActivityResult } - - ProgressDialogFragment.newInstance( - this, - R.string.exporting_user_data, - true - ) { progressCallback, _ -> - val zipResult = FileUtil.zipFromInternalStorage( - File(DirectoryInitialization.userDirectory!!), - DirectoryInitialization.userDirectory!!, - BufferedOutputStream(contentResolver.openOutputStream(result)), - progressCallback, - compression = false - ) - return@newInstance when (zipResult) { - TaskState.Completed -> getString(R.string.user_data_export_success) - TaskState.Failed -> R.string.export_failed - TaskState.Cancelled -> R.string.user_data_export_cancelled - } - }.show(supportFragmentManager, ProgressDialogFragment.TAG) + InstallableActions.exportUserData( + activity = this, + fragmentManager = supportFragmentManager, + result = result + ) } val importUserData = @@ -735,58 +549,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (result == null) { return@registerForActivityResult } - - ProgressDialogFragment.newInstance( - this, - R.string.importing_user_data - ) { progressCallback, _ -> - val checkStream = - ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) - var isYuzuBackup = false - checkStream.use { stream -> - var ze: ZipEntry? = null - while (stream.nextEntry?.also { ze = it } != null) { - val itemName = ze!!.name.trim() - if (itemName == "/config/config.ini" || itemName == "config/config.ini") { - isYuzuBackup = true - return@use - } - } - } - if (!isYuzuBackup) { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.invalid_yuzu_backup, - descriptionId = R.string.user_data_import_failed_description - ) - } - - // Clear existing user data - NativeConfig.unloadGlobalConfig() - File(DirectoryInitialization.userDirectory!!).deleteRecursively() - - // Copy archive to internal storage - try { - FileUtil.unzipToInternalStorage( - result.toString(), - File(DirectoryInitialization.userDirectory!!), - progressCallback - ) - } catch (e: Exception) { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.import_failed, - descriptionId = R.string.user_data_import_failed_description - ) - } - - // Reinitialize relevant data - NativeLibrary.initializeSystem(true) - NativeConfig.initializeGlobalConfig() - gamesViewModel.reloadGames(false) - driverViewModel.reloadDriverData() - - return@newInstance getString(R.string.user_data_import_success) - }.show(supportFragmentManager, ProgressDialogFragment.TAG) + InstallableActions.importUserData( + activity = this, + fragmentManager = supportFragmentManager, + gamesViewModel = gamesViewModel, + driverViewModel = driverViewModel, + result = result + ) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InstallableActions.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InstallableActions.kt new file mode 100644 index 0000000000..d385e2a095 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InstallableActions.kt @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.InstallResult +import org.yuzu.yuzu_emu.model.TaskState +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FilenameFilter +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +object InstallableActions { + fun processKey( + activity: FragmentActivity, + fragmentManager: FragmentManager, + gamesViewModel: GamesViewModel, + result: Uri, + extension: String = "keys" + ) { + activity.contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val resultCode = NativeLibrary.installKeys(result.toString(), extension) + if (resultCode == 0) { + Toast.makeText( + activity.applicationContext, + R.string.keys_install_success, + Toast.LENGTH_SHORT + ).show() + gamesViewModel.reloadGames(true) + return + } + + val resultString = activity.resources.getStringArray(R.array.installKeysResults)[resultCode] + MessageDialogFragment.newInstance( + titleId = R.string.keys_failed, + descriptionString = resultString, + helpLinkId = R.string.keys_missing_help + ).show(fragmentManager, MessageDialogFragment.TAG) + } + + fun processFirmware( + activity: FragmentActivity, + fragmentManager: FragmentManager, + homeViewModel: HomeViewModel, + result: Uri, + onComplete: (() -> Unit)? = null + ) { + val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } + val firmwarePath = File(NativeConfig.getNandDir() + "/system/Contents/registered/") + val cacheFirmwareDir = File("${activity.cacheDir.path}/registered/") + + ProgressDialogFragment.newInstance( + activity, + R.string.firmware_installing + ) { progressCallback, _ -> + var messageToShow: Any + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheFirmwareDir, + progressCallback + ) + val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 + val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 + messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { + MessageDialogFragment.newInstance( + activity, + titleId = R.string.firmware_installed_failure, + descriptionId = R.string.firmware_installed_failure_description + ) + } else { + firmwarePath.deleteRecursively() + cacheFirmwareDir.copyRecursively(firmwarePath, overwrite = true) + NativeLibrary.initializeSystem(true) + homeViewModel.setCheckKeys(true) + activity.getString(R.string.save_file_imported_success) + } + } catch (_: Exception) { + messageToShow = activity.getString(R.string.fatal_error) + } finally { + cacheFirmwareDir.deleteRecursively() + } + messageToShow + }.apply { + onDialogComplete = onComplete + }.show(fragmentManager, ProgressDialogFragment.TAG) + } + + fun uninstallFirmware( + activity: FragmentActivity, + fragmentManager: FragmentManager, + homeViewModel: HomeViewModel + ) { + val firmwarePath = File(NativeConfig.getNandDir() + "/system/Contents/registered/") + ProgressDialogFragment.newInstance( + activity, + R.string.firmware_uninstalling + ) { _, _ -> + val messageToShow: Any = try { + if (firmwarePath.exists()) { + firmwarePath.deleteRecursively() + NativeLibrary.initializeSystem(true) + homeViewModel.setCheckKeys(true) + activity.getString(R.string.firmware_uninstalled_success) + } else { + activity.getString(R.string.firmware_uninstalled_failure) + } + } catch (_: Exception) { + activity.getString(R.string.fatal_error) + } + messageToShow + }.show(fragmentManager, ProgressDialogFragment.TAG) + } + + fun installContent( + activity: FragmentActivity, + fragmentManager: FragmentManager, + addonViewModel: AddonViewModel, + documents: List + ) { + ProgressDialogFragment.newInstance( + activity, + R.string.installing_game_content + ) { progressCallback, messageCallback -> + var installSuccess = 0 + var installOverwrite = 0 + var errorBaseGame = 0 + var error = 0 + documents.forEach { + messageCallback.invoke(FileUtil.getFilename(it)) + when ( + InstallResult.from( + NativeLibrary.installFileToNand( + it.toString(), + progressCallback + ) + ) + ) { + InstallResult.Success -> installSuccess += 1 + InstallResult.Overwrite -> installOverwrite += 1 + InstallResult.BaseInstallAttempted -> errorBaseGame += 1 + InstallResult.Failure -> error += 1 + } + } + + addonViewModel.refreshAddons(force = true) + + val separator = System.lineSeparator() ?: "\n" + val installResult = StringBuilder() + if (installSuccess > 0) { + installResult.append( + activity.getString( + R.string.install_game_content_success_install, + installSuccess + ) + ) + installResult.append(separator) + } + if (installOverwrite > 0) { + installResult.append( + activity.getString( + R.string.install_game_content_success_overwrite, + installOverwrite + ) + ) + installResult.append(separator) + } + val errorTotal = errorBaseGame + error + if (errorTotal > 0) { + installResult.append(separator) + installResult.append( + activity.getString( + R.string.install_game_content_failed_count, + errorTotal + ) + ) + installResult.append(separator) + if (errorBaseGame > 0) { + installResult.append(separator) + installResult.append(activity.getString(R.string.install_game_content_failure_base)) + installResult.append(separator) + } + if (error > 0) { + installResult.append( + activity.getString(R.string.install_game_content_failure_description) + ) + installResult.append(separator) + } + return@newInstance MessageDialogFragment.newInstance( + activity, + titleId = R.string.install_game_content_failure, + descriptionString = installResult.toString().trim(), + helpLinkId = R.string.install_game_content_help_link + ) + } else { + return@newInstance MessageDialogFragment.newInstance( + activity, + titleId = R.string.install_game_content_success, + descriptionString = installResult.toString().trim() + ) + } + }.show(fragmentManager, ProgressDialogFragment.TAG) + } + + fun exportUserData( + activity: FragmentActivity, + fragmentManager: FragmentManager, + result: Uri + ) { + val userDirectory = DirectoryInitialization.userDirectory + if (userDirectory == null) { + Toast.makeText( + activity.applicationContext, + R.string.fatal_error, + Toast.LENGTH_SHORT + ).show() + return + } + + ProgressDialogFragment.newInstance( + activity, + R.string.exporting_user_data, + true + ) { progressCallback, _ -> + val zipResult = FileUtil.zipFromInternalStorage( + File(userDirectory), + userDirectory, + BufferedOutputStream(activity.contentResolver.openOutputStream(result)), + progressCallback, + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> activity.getString(R.string.user_data_export_success) + TaskState.Failed -> R.string.export_failed + TaskState.Cancelled -> R.string.user_data_export_cancelled + } + }.show(fragmentManager, ProgressDialogFragment.TAG) + } + + fun importUserData( + activity: FragmentActivity, + fragmentManager: FragmentManager, + gamesViewModel: GamesViewModel, + driverViewModel: DriverViewModel, + result: Uri + ) { + val userDirectory = DirectoryInitialization.userDirectory + if (userDirectory == null) { + Toast.makeText( + activity.applicationContext, + R.string.fatal_error, + Toast.LENGTH_SHORT + ).show() + return + } + + ProgressDialogFragment.newInstance( + activity, + R.string.importing_user_data + ) { progressCallback, _ -> + val checkStream = ZipInputStream( + BufferedInputStream(activity.contentResolver.openInputStream(result)) + ) + var isYuzuBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + val itemName = ze!!.name.trim() + if (itemName == "/config/config.ini" || itemName == "config/config.ini") { + isYuzuBackup = true + return@use + } + } + } + if (!isYuzuBackup) { + return@newInstance MessageDialogFragment.newInstance( + activity, + titleId = R.string.invalid_yuzu_backup, + descriptionId = R.string.user_data_import_failed_description + ) + } + + NativeConfig.unloadGlobalConfig() + File(userDirectory).deleteRecursively() + + try { + FileUtil.unzipToInternalStorage( + result.toString(), + File(userDirectory), + progressCallback + ) + } catch (_: Exception) { + return@newInstance MessageDialogFragment.newInstance( + activity, + titleId = R.string.import_failed, + descriptionId = R.string.user_data_import_failed_description + ) + } + + NativeLibrary.initializeSystem(true) + NativeConfig.initializeGlobalConfig() + gamesViewModel.reloadGames(false) + driverViewModel.reloadDriverData() + + return@newInstance activity.getString(R.string.user_data_import_success) + }.show(fragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_about.xml b/src/android/app/src/main/res/layout-w600dp/fragment_about.xml index ae2b3e3637..cc8d26dd58 100644 --- a/src/android/app/src/main/res/layout-w600dp/fragment_about.xml +++ b/src/android/app/src/main/res/layout-w600dp/fragment_about.xml @@ -10,12 +10,11 @@ + android:touchscreenBlocksFocus="false"> + android:paddingBottom="24dp" + android:paddingStart="24dp" + android:paddingTop="0dp" + android:paddingEnd="24dp"> - + android:gravity="center_horizontal" + android:orientation="vertical"> + + + + + + + + - - - - - - - - - - diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml index da823bcc25..b1b896f169 100644 --- a/src/android/app/src/main/res/layout/fragment_about.xml +++ b/src/android/app/src/main/res/layout/fragment_about.xml @@ -10,12 +10,11 @@ + android:touchscreenBlocksFocus="false"> - - - - + + + + + android:text="@string/app_name" + android:textAlignment="center" /> - + - - - - + @@ -220,7 +206,9 @@ app:icon="@drawable/ic_discord" app:iconSize="24dp" app:iconGravity="textStart" - app:iconPadding="0dp" /> + app:iconPadding="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" /> + app:iconPadding="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" /> + app:iconPadding="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" /> + app:iconPadding="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" /> + app:iconPadding="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" /> diff --git a/src/android/app/src/main/res/navigation/emulation_navigation.xml b/src/android/app/src/main/res/navigation/emulation_navigation.xml index 5e6a49501d..2adc60a47c 100644 --- a/src/android/app/src/main/res/navigation/emulation_navigation.xml +++ b/src/android/app/src/main/res/navigation/emulation_navigation.xml @@ -40,10 +40,6 @@ + app:destination="@id/settingsActivity" /> 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 7d04a19f36..dd567abc1a 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -20,26 +20,7 @@ - - - - - - - + android:label="HomeSettingsFragment" /> - - + android:label="AboutFragment" /> + app:destination="@id/settingsActivity" /> + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 7d094effcb..83c04ad40e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -400,7 +400,7 @@ Copied to clipboard An open-source Switch emulator Contributors - Contributors who made Eden for Android possible + People who made Eden for Android possible https://git.eden-emu.dev/eden-emu/eden/activity/contributors Projects that make Eden for Android possible Build