Browse Source

Add import / export, rework Secondary Action UX

pull/478/head
inix 5 months ago
parent
commit
a136b4b6f2
  1. 34
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
  2. 96
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
  3. 7
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt
  4. 34
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
  5. 32
      src/android/app/src/main/res/layout/card_simple_outlined.xml
  6. 5
      src/android/app/src/main/res/values/strings.xml

34
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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -84,15 +87,32 @@ class GamePropertiesAdapter(
binding.details.setVisible(false) 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 { } else {
binding.buttonSecondaryAction.setVisible(false)
binding.dividerSecondaryActions.setVisible(false)
binding.layoutSecondaryActions.setVisible(false)
} }
} }
} }

96
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-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // 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.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.model.InstallableProperty 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.SubmenuProperty
import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.model.TaskState
import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DirectoryInitialization
@ -162,11 +165,32 @@ class GamePropertiesFragment : Fragment() {
) )
binding.root.findNavController().navigate(action) binding.root.findNavController().navigate(action)
}, },
secondaryAction = SubMenuProperSecondaryAction(
isShown = File(
secondaryActions = buildList {
val configExists = File(
DirectoryInitialization.userDirectory + DirectoryInitialization.userDirectory +
"/config/custom/" + args.game.settingsName + ".ini" "/config/custom/" + args.game.settingsName + ".ini"
).exists(),
).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, descriptionId = R.string.share_game_settings,
iconId = R.drawable.ic_share, iconId = R.drawable.ic_share,
action = { action = {
@ -178,7 +202,8 @@ class GamePropertiesFragment : Fragment() {
shareConfigFile(configFile) shareConfigFile(configFile)
} }
} }
)
))
}
) )
) )
@ -449,6 +474,67 @@ class GamePropertiesFragment : Fragment() {
}.show(parentFragmentManager, ProgressDialogFragment.TAG) }.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) { private fun shareConfigFile(configFile: File) {
val file = DocumentFile.fromSingleUri( val file = DocumentFile.fromSingleUri(
requireContext(), requireContext(),

7
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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -25,10 +28,10 @@ data class SubmenuProperty(
val details: (() -> String)? = null, val details: (() -> String)? = null,
val detailsFlow: StateFlow<String>? = null, val detailsFlow: StateFlow<String>? = null,
val action: () -> Unit, val action: () -> Unit,
val secondaryAction: SubMenuProperSecondaryAction? = null
val secondaryActions: List<SubMenuPropertySecondaryAction>? = null
) : GameProperty ) : GameProperty
data class SubMenuProperSecondaryAction(
data class SubMenuPropertySecondaryAction(
val isShown : Boolean, val isShown : Boolean,
val descriptionId: Int, val descriptionId: Int,
val iconId: Int, val iconId: Int,

34
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.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import org.yuzu.yuzu_emu.YuzuApplication 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.MinimalDocumentFile
import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.model.TaskState
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
@ -291,6 +292,39 @@ object FileUtil {
null 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. * Extracts the given zip file into the given directory.
* @param path String representation of a [Uri] or a typical path delimited by '/' * @param path String representation of a [Uri] or a typical path delimited by '/'

32
src/android/app/src/main/res/layout/card_simple_outlined.xml

@ -11,6 +11,11 @@
android:clickable="true" android:clickable="true"
android:focusable="true"> android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -67,17 +72,24 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonSecondaryAction"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/dividerSecondaryActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
app:iconSize="20dp"
tools:visibility="visible"
tools:icon="@drawable/ic_info_outline" />
tools:visibility="visible" />
</LinearLayout>
<com.google.android.material.chip.ChipGroup
android:id="@+id/layoutSecondaryActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp"
android:visibility="gone"
app:singleLine="false"
tools:visibility="visible" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

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

@ -677,6 +677,7 @@
<string name="fetch">Fetch</string> <string name="fetch">Fetch</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="edit">Edit</string> <string name="edit">Edit</string>
<string name="import_success">Imported successfully</string>
<string name="export_success">Exported successfully</string> <string name="export_success">Exported successfully</string>
<string name="start">Start</string> <string name="start">Start</string>
<string name="clear">Clear</string> <string name="clear">Clear</string>
@ -791,7 +792,9 @@
<string name="verify_no_result">Integrity verification couldn\'t be performed</string> <string name="verify_no_result">Integrity verification couldn\'t be performed</string>
<string name="verify_no_result_description">File contents were not checked for validity</string> <string name="verify_no_result_description">File contents were not checked for validity</string>
<string name="verification_failed_for">Verification failed for the following files:\n%1$s</string> <string name="verification_failed_for">Verification failed for the following files:\n%1$s</string>
<string name="share_game_settings">Share Game Settings</string>
<string name="share_game_settings">Share Config</string>
<string name="import_config">Import Config</string>
<string name="export_config">Export Config</string>
<string name="share_config_failed">Failed to share configuration file</string> <string name="share_config_failed">Failed to share configuration file</string>
<!-- ROM loading errors --> <!-- ROM loading errors -->

Loading…
Cancel
Save