[android, gameProperties] Add support for sharing per-game config file #478

Open
inix wants to merge 3 commits from inix/eden:share-config-file into master
6 changed files with 332 additions and 88 deletions

View file

@ -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
@ -83,6 +86,34 @@ class GamePropertiesAdapter(
} else { } else {
binding.details.setVisible(false) 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)
}
} }
} }

View file

@ -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-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments package org.yuzu.yuzu_emu.fragments
import android.content.Intent
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -14,6 +19,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope 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.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding 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.model.Settings
import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.DriverViewModel
import org.yuzu.yuzu_emu.model.GameProperty 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.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
@ -137,25 +145,66 @@ class GamePropertiesFragment : Fragment() {
SubmenuProperty( SubmenuProperty(
R.string.info, R.string.info,
R.string.info_description, R.string.info_description,
R.drawable.ic_info_outline R.drawable.ic_info_outline,
) { action = {
val action = GamePropertiesFragmentDirections val action = GamePropertiesFragmentDirections
.actionPerGamePropertiesFragmentToGameInfoFragment(args.game) .actionPerGamePropertiesFragmentToGameInfoFragment(args.game)
binding.root.findNavController().navigate(action) binding.root.findNavController().navigate(action)
} }
) )
)
add( add(
SubmenuProperty( SubmenuProperty(
R.string.preferences_settings, R.string.preferences_settings,
R.string.per_game_settings_description, R.string.per_game_settings_description,
R.drawable.ic_settings R.drawable.ic_settings,
) { action = {
val action = HomeNavigationDirections.actionGlobalSettingsActivity( val action = HomeNavigationDirections.actionGlobalSettingsActivity(
args.game, args.game,
Settings.MenuTag.SECTION_ROOT Settings.MenuTag.SECTION_ROOT
) )
binding.root.findNavController().navigate(action) 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()) { if (GpuDriverHelper.supportsCustomDriverLoading()) {
@ -164,13 +213,14 @@ class GamePropertiesFragment : Fragment() {
R.string.gpu_driver_manager, R.string.gpu_driver_manager,
R.string.install_gpu_driver_description, R.string.install_gpu_driver_description,
R.drawable.ic_build, R.drawable.ic_build,
detailsFlow = driverViewModel.selectedDriverTitle detailsFlow = driverViewModel.selectedDriverTitle,
) { action = {
val action = GamePropertiesFragmentDirections val action = GamePropertiesFragmentDirections
.actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game)
binding.root.findNavController().navigate(action) binding.root.findNavController().navigate(action)
} }
) )
)
} }
if (!args.game.isHomebrew) { if (!args.game.isHomebrew) {
@ -178,13 +228,14 @@ class GamePropertiesFragment : Fragment() {
SubmenuProperty( SubmenuProperty(
R.string.add_ons, R.string.add_ons,
R.string.add_ons_description, R.string.add_ons_description,
R.drawable.ic_edit R.drawable.ic_edit,
) { action = {
val action = GamePropertiesFragmentDirections val action = GamePropertiesFragmentDirections
.actionPerGamePropertiesFragmentToAddonsFragment(args.game) .actionPerGamePropertiesFragmentToAddonsFragment(args.game)
binding.root.findNavController().navigate(action) binding.root.findNavController().navigate(action)
} }
) )
)
add( add(
InstallableProperty( InstallableProperty(
R.string.save_data, R.string.save_data,
@ -245,7 +296,7 @@ class GamePropertiesFragment : Fragment() {
R.string.clear_shader_cache, R.string.clear_shader_cache,
R.string.clear_shader_cache_description, R.string.clear_shader_cache_description,
R.drawable.ic_delete, R.drawable.ic_delete,
{ details = {
if (shaderCacheDir.exists()) { if (shaderCacheDir.exists()) {
val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } val bytes = shaderCacheDir.walkTopDown().filter { it.isFile }
.map { it.length() }.sum() .map { it.length() }.sum()
@ -253,8 +304,8 @@ class GamePropertiesFragment : Fragment() {
} else { } else {
MemoryUtil.bytesToSizeUnit(0f) MemoryUtil.bytesToSizeUnit(0f)
} }
} },
) { action = {
MessageDialogFragment.newInstance( MessageDialogFragment.newInstance(
requireActivity(), requireActivity(),
titleId = R.string.clear_shader_cache, titleId = R.string.clear_shader_cache,
@ -271,6 +322,7 @@ class GamePropertiesFragment : Fragment() {
).show(parentFragmentManager, MessageDialogFragment.TAG) ).show(parentFragmentManager, MessageDialogFragment.TAG)
} }
) )
)
} }
} }
} }
@ -284,6 +336,7 @@ class GamePropertiesFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
driverViewModel.updateDriverNameForGame(args.game) driverViewModel.updateDriverNameForGame(args.game)
reloadList()
} }
private fun setInsets() = private fun setInsets() =
@ -420,4 +473,91 @@ 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) {
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()
}
}
} }

View file

@ -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
@ -24,9 +27,17 @@ data class SubmenuProperty(
override val iconId: Int, override val iconId: Int,
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 secondaryActions: List<SubMenuPropertySecondaryAction>? = null
) : GameProperty ) : GameProperty
data class SubMenuPropertySecondaryAction(
val isShown : Boolean,
val descriptionId: Int,
val iconId: Int,
val action: () -> Unit
)
data class InstallableProperty( data class InstallableProperty(
override val titleId: Int, override val titleId: Int,
override val descriptionId: Int, override val descriptionId: Int,

View file

@ -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 '/'

View file

@ -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"
@ -69,4 +74,22 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/dividerSecondaryActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible" />
<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>

View file

@ -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,6 +792,10 @@
<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 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 --> <!-- ROM loading errors -->
<string name="loader_error_encrypted">Your ROM is encrypted</string> <string name="loader_error_encrypted">Your ROM is encrypted</string>