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. 116
      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. 118
      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-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)
}
}
}

116
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(),

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

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

@ -14,70 +14,82 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center"
android:paddingVertical="16dp"
android:paddingHorizontal="24dp">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="20dp"
android:layout_gravity="center_vertical"
app:tint="?attr/colorOnSurface" />
android:orientation="vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_gravity="center_vertical">
android:orientation="horizontal"
android:layout_gravity="center"
android:paddingVertical="16dp"
android:paddingHorizontal="24dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
tools:text="@string/applets" />
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="20dp"
android:layout_gravity="center_vertical"
app:tint="?attr/colorOnSurface" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/description"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAlignment="viewStart"
tools:text="@string/applets_description" />
android:layout_weight="1"
android:orientation="vertical"
android:layout_gravity="center_vertical">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.LabelMedium"
android:id="@+id/details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textSize="14sp"
android:textStyle="bold"
android:requiresFadingEdge="horizontal"
android:layout_marginTop="6dp"
android:visibility="gone"
tools:visibility="visible"
tools:text="/tree/primary:Games" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
tools:text="@string/applets" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/description"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAlignment="viewStart"
tools:text="@string/applets_description" />
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.LabelMedium"
android:id="@+id/details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textSize="14sp"
android:textStyle="bold"
android:requiresFadingEdge="horizontal"
android:layout_marginTop="6dp"
android:visibility="gone"
tools:visibility="visible"
tools:text="/tree/primary:Games" />
</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"
<com.google.android.material.divider.MaterialDivider
android:id="@+id/dividerSecondaryActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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>

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

@ -677,6 +677,7 @@
<string name="fetch">Fetch</string>
<string name="delete">Delete</string>
<string name="edit">Edit</string>
<string name="import_success">Imported successfully</string>
<string name="export_success">Exported successfully</string>
<string name="start">Start</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_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="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>
<!-- ROM loading errors -->

Loading…
Cancel
Save