From a136b4b6f20552f1c270e46eab7fb00154a38739 Mon Sep 17 00:00:00 2001 From: inix Date: Sun, 12 Oct 2025 18:55:28 +0200 Subject: [PATCH] Add import / export, rework Secondary Action UX --- .../adapters/GamePropertiesAdapter.kt | 34 +++-- .../fragments/GamePropertiesFragment.kt | 116 ++++++++++++++--- .../org/yuzu/yuzu_emu/model/GameProperties.kt | 7 +- .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 34 +++++ .../main/res/layout/card_simple_outlined.xml | 118 ++++++++++-------- .../app/src/main/res/values/strings.xml | 5 +- 6 files changed, 236 insertions(+), 78 deletions(-) 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 73a96bf17c..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 @@ -84,15 +87,32 @@ class GamePropertiesAdapter( binding.details.setVisible(false) } - if (submenuProperty.secondaryAction != null) { - binding.buttonSecondaryAction.setVisible(submenuProperty.secondaryAction.isShown) - binding.buttonSecondaryAction.setIconResource(submenuProperty.secondaryAction.iconId) - binding.buttonSecondaryAction.contentDescription = binding.buttonSecondaryAction.context.getString(submenuProperty.secondaryAction.descriptionId) - binding.buttonSecondaryAction.setOnClickListener { - submenuProperty.secondaryAction.action.invoke() + + 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.buttonSecondaryAction.setVisible(false) + 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 af4edfa1e0..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,3 +1,6 @@ +// 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 @@ -39,7 +42,7 @@ 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.SubMenuProperSecondaryAction +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 @@ -162,23 +165,45 @@ class GamePropertiesFragment : Fragment() { ) binding.root.findNavController().navigate(action) }, - secondaryAction = SubMenuProperSecondaryAction( - isShown = File( + secondaryActions = buildList { + val configExists = File( DirectoryInitialization.userDirectory + "/config/custom/" + args.game.settingsName + ".ini" - ).exists(), - 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) + ).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) + } + } + )) + } ) ) @@ -449,6 +474,67 @@ 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(), 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 0b3c1f59f8..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 @@ -25,10 +28,10 @@ data class SubmenuProperty( val details: (() -> String)? = null, val detailsFlow: StateFlow? = null, val action: () -> Unit, - val secondaryAction: SubMenuProperSecondaryAction? = null + val secondaryActions: List? = null ) : GameProperty -data class SubMenuProperSecondaryAction( +data class SubMenuPropertySecondaryAction( val isShown : Boolean, val descriptionId: Int, val iconId: 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 4f64b3fdd9..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,70 +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"> - + + + + + + + - + tools:visibility="visible" /> - + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 6adf768373..1998e9112b 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -677,6 +677,7 @@ Fetch Delete Edit + Imported successfully Exported successfully Start Clear @@ -791,7 +792,9 @@ Integrity verification couldn\'t be performed File contents were not checked for validity Verification failed for the following files:\n%1$s - Share Game Settings + Share Config + Import Config + Export Config Failed to share configuration file