diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt index 7366e2c778..a8ec82e560 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -83,6 +86,34 @@ class GamePropertiesAdapter( } else { binding.details.setVisible(false) } + + + val hasVisibleActions = submenuProperty.secondaryActions?.any { it.isShown } == true + + if (hasVisibleActions) { + binding.dividerSecondaryActions.setVisible(true) + binding.layoutSecondaryActions.setVisible(true) + + submenuProperty.secondaryActions!!.forEach { secondaryAction -> + if (secondaryAction.isShown) { + val button = com.google.android.material.button.MaterialButton( + binding.root.context, + null, + com.google.android.material.R.attr.materialButtonOutlinedStyle + ).apply { + setIconResource(secondaryAction.iconId) + iconSize = (18 * binding.root.context.resources.displayMetrics.density).toInt() + text = binding.root.context.getString(secondaryAction.descriptionId) + contentDescription = binding.root.context.getString(secondaryAction.descriptionId) + setOnClickListener { secondaryAction.action.invoke() } + } + binding.layoutSecondaryActions.addView(button) + } + } + } else { + binding.dividerSecondaryActions.setVisible(false) + binding.layoutSecondaryActions.setVisible(false) + } } } 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 f55edb418e..6979409cad 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 @@ -1,11 +1,16 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2025 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments +import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.os.Bundle +import android.provider.DocumentsContract import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -14,6 +19,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope @@ -29,12 +35,14 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication 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.model.DriverViewModel import org.yuzu.yuzu_emu.model.GameProperty import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.InstallableProperty +import org.yuzu.yuzu_emu.model.SubMenuPropertySecondaryAction import org.yuzu.yuzu_emu.model.SubmenuProperty import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.utils.DirectoryInitialization @@ -137,25 +145,66 @@ class GamePropertiesFragment : Fragment() { SubmenuProperty( R.string.info, R.string.info_description, - R.drawable.ic_info_outline - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) - binding.root.findNavController().navigate(action) - } + R.drawable.ic_info_outline, + action = { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) ) add( SubmenuProperty( R.string.preferences_settings, R.string.per_game_settings_description, - R.drawable.ic_settings - ) { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - args.game, - Settings.MenuTag.SECTION_ROOT - ) - binding.root.findNavController().navigate(action) - } + R.drawable.ic_settings, + action = { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + }, + secondaryActions = buildList { + val configExists = File( + DirectoryInitialization.userDirectory + + "/config/custom/" + args.game.settingsName + ".ini" + ).exists() + + add(SubMenuPropertySecondaryAction( + isShown = configExists, + descriptionId = R.string.import_config, + iconId = R.drawable.ic_import, + action = { + importConfig.launch(arrayOf("text/ini", "application/octet-stream")) + } + )) + + add(SubMenuPropertySecondaryAction( + isShown = configExists, + descriptionId = R.string.export_config, + iconId = R.drawable.ic_export, + action = { + exportConfig.launch(args.game.settingsName + ".ini") + } + )) + + add(SubMenuPropertySecondaryAction( + isShown = configExists, + descriptionId = R.string.share_game_settings, + iconId = R.drawable.ic_share, + action = { + val configFile = File( + DirectoryInitialization.userDirectory + + "/config/custom/" + args.game.settingsName + ".ini" + ) + if (configFile.exists()) { + shareConfigFile(configFile) + } + } + )) + } + ) ) if (GpuDriverHelper.supportsCustomDriverLoading()) { @@ -164,12 +213,13 @@ class GamePropertiesFragment : Fragment() { R.string.gpu_driver_manager, R.string.install_gpu_driver_description, R.drawable.ic_build, - detailsFlow = driverViewModel.selectedDriverTitle - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) - binding.root.findNavController().navigate(action) - } + detailsFlow = driverViewModel.selectedDriverTitle, + action = { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) ) } @@ -178,12 +228,13 @@ class GamePropertiesFragment : Fragment() { SubmenuProperty( R.string.add_ons, R.string.add_ons_description, - R.drawable.ic_edit - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToAddonsFragment(args.game) - binding.root.findNavController().navigate(action) - } + R.drawable.ic_edit, + action = { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToAddonsFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) ) add( InstallableProperty( @@ -245,7 +296,7 @@ class GamePropertiesFragment : Fragment() { R.string.clear_shader_cache, R.string.clear_shader_cache_description, R.drawable.ic_delete, - { + details = { if (shaderCacheDir.exists()) { val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } .map { it.length() }.sum() @@ -253,23 +304,24 @@ class GamePropertiesFragment : Fragment() { } else { MemoryUtil.bytesToSizeUnit(0f) } + }, + action = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.clear_shader_cache, + descriptionId = R.string.clear_shader_cache_warning_description, + positiveAction = { + shaderCacheDir.deleteRecursively() + Toast.makeText( + YuzuApplication.appContext, + R.string.cleared_shaders_successfully, + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) } - ) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.clear_shader_cache, - descriptionId = R.string.clear_shader_cache_warning_description, - positiveAction = { - shaderCacheDir.deleteRecursively() - Toast.makeText( - YuzuApplication.appContext, - R.string.cleared_shaders_successfully, - Toast.LENGTH_SHORT - ).show() - homeViewModel.reloadPropertiesList(true) - } - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } + ) ) } } @@ -284,6 +336,7 @@ class GamePropertiesFragment : Fragment() { override fun onResume() { super.onResume() driverViewModel.updateDriverNameForGame(args.game) + reloadList() } private fun setInsets() = @@ -420,4 +473,91 @@ class GamePropertiesFragment : Fragment() { } }.show(parentFragmentManager, ProgressDialogFragment.TAG) } + + /** + * Imports an ini file from external storage to internal app directory and override per-game config + */ + private val importConfig = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + val iniResult = FileUtil.copyUriToInternalStorage( + sourceUri = result, + destinationParentPath = + DirectoryInitialization.userDirectory + "/config/custom/", + destinationFilename = args.game.settingsName + ".ini" + ) + if (iniResult?.exists() == true) { + Toast.makeText( + requireContext(), + getString(R.string.import_success), + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } else { + Toast.makeText( + requireContext(), + getString(R.string.import_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + /** + * Exports game's config ini to the specified location in external storage + */ + private val exportConfig = registerForActivityResult( + ActivityResultContracts.CreateDocument("text/ini") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { _, _ -> + val configLocation = DirectoryInitialization.userDirectory + + "/config/custom/" + args.game.settingsName + ".ini" + + val iniResult = FileUtil.copyToExternalStorage( + sourcePath = configLocation, + destUri = result + ) + return@newInstance when (iniResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private fun shareConfigFile(configFile: File) { + val file = DocumentFile.fromSingleUri( + requireContext(), + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/${configFile}" + ) + )!! + + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(file.uri, FileUtil.TEXT_PLAIN) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (file.exists()) { + intent.putExtra(Intent.EXTRA_STREAM, file.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_game_settings))) + } else { + Toast.makeText( + requireContext(), + getText(R.string.share_config_failed), + Toast.LENGTH_SHORT + ).show() + } + + } + } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt index 0135a95beb..a186b91688 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -24,9 +27,17 @@ data class SubmenuProperty( override val iconId: Int, val details: (() -> String)? = null, val detailsFlow: StateFlow? = null, - val action: () -> Unit + val action: () -> Unit, + val secondaryActions: List? = null ) : GameProperty +data class SubMenuPropertySecondaryAction( + val isShown : Boolean, + val descriptionId: Int, + val iconId: Int, + val action: () -> Unit +) + data class InstallableProperty( override val titleId: Int, override val descriptionId: Int, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index 52ee7b01ea..1deba1aade 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -18,6 +18,7 @@ import java.net.URLDecoder import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.model.MinimalDocumentFile import org.yuzu.yuzu_emu.model.TaskState import java.io.BufferedOutputStream @@ -291,6 +292,39 @@ object FileUtil { null } + /** + * Copies a file from internal appdata storage to an external Uri. + */ + fun copyToExternalStorage( + sourcePath: String, + destUri: Uri, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ): TaskState { + try { + val totalBytes = getFileSize(sourcePath) + var progressBytes = 0L + val inputStream = getInputStream(sourcePath) + BufferedInputStream(inputStream).use { bis -> + context.contentResolver.openOutputStream(destUri, "wt")?.use { outputStream -> + val buffer = ByteArray(1024 * 4) + var len: Int + while (bis.read(buffer).also { len = it } != -1) { + if (progressCallback.invoke(totalBytes, progressBytes)) { + return TaskState.Cancelled + } + outputStream.write(buffer, 0, len) + progressBytes += len + } + outputStream.flush() + } ?: return TaskState.Failed + } + } catch (e: Exception) { + Log.error("[FileUtil] Failed exporting file - ${e.message}") + return TaskState.Failed + } + return TaskState.Completed + } + /** * Extracts the given zip file into the given directory. * @param path String representation of a [Uri] or a typical path delimited by '/' diff --git a/src/android/app/src/main/res/layout/card_simple_outlined.xml b/src/android/app/src/main/res/layout/card_simple_outlined.xml index e29df6a2de..13cca80574 100644 --- a/src/android/app/src/main/res/layout/card_simple_outlined.xml +++ b/src/android/app/src/main/res/layout/card_simple_outlined.xml @@ -14,59 +14,82 @@ - - + android:orientation="vertical"> + android:orientation="horizontal" + android:layout_gravity="center" + android:paddingVertical="16dp" + android:paddingHorizontal="24dp"> - + - + android:layout_weight="1" + android:orientation="vertical" + android:layout_gravity="center_vertical"> - + + + + + + + - + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 50dc68e000..fd1e7d42fc 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -675,6 +675,7 @@ Fetch Delete Edit + Imported successfully Exported successfully Start Clear @@ -789,6 +790,10 @@ Integrity verification couldn\'t be performed File contents were not checked for validity Verification failed for the following files:\n%1$s + Share Config + Import Config + Export Config + Failed to share configuration file Your ROM is encrypted