[android, gameProperties] Add support for sharing per-game config file (#478)
Firstly i added secondary action support for the Sub Menu Properties as a button on the right side of the card. This may be handy in the future when adding more complex functions to Game Properties. For now i just added the ability to share the per game config file like the already existing log sharing function, this could be useful for EmuReady maybe. Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/478 Reviewed-by: crueter <crueter@eden-emu.dev> Reviewed-by: CamilleLaVey <camillelavey99@gmail.com> Co-authored-by: inix <Nixy01@proton.me> Co-committed-by: inix <Nixy01@proton.me>
This commit is contained in:
parent
c0fb872d1a
commit
e2a8f3154f
6 changed files with 332 additions and 88 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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,13 +213,14 @@ class GamePropertiesFragment : Fragment() {
|
|||
R.string.gpu_driver_manager,
|
||||
R.string.install_gpu_driver_description,
|
||||
R.drawable.ic_build,
|
||||
detailsFlow = driverViewModel.selectedDriverTitle
|
||||
) {
|
||||
detailsFlow = driverViewModel.selectedDriverTitle,
|
||||
action = {
|
||||
val action = GamePropertiesFragmentDirections
|
||||
.actionPerGamePropertiesFragmentToDriverManagerFragment(args.game)
|
||||
binding.root.findNavController().navigate(action)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!args.game.isHomebrew) {
|
||||
|
|
@ -178,13 +228,14 @@ class GamePropertiesFragment : Fragment() {
|
|||
SubmenuProperty(
|
||||
R.string.add_ons,
|
||||
R.string.add_ons_description,
|
||||
R.drawable.ic_edit
|
||||
) {
|
||||
R.drawable.ic_edit,
|
||||
action = {
|
||||
val action = GamePropertiesFragmentDirections
|
||||
.actionPerGamePropertiesFragmentToAddonsFragment(args.game)
|
||||
binding.root.findNavController().navigate(action)
|
||||
}
|
||||
)
|
||||
)
|
||||
add(
|
||||
InstallableProperty(
|
||||
R.string.save_data,
|
||||
|
|
@ -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,8 +304,8 @@ class GamePropertiesFragment : Fragment() {
|
|||
} else {
|
||||
MemoryUtil.bytesToSizeUnit(0f)
|
||||
}
|
||||
}
|
||||
) {
|
||||
},
|
||||
action = {
|
||||
MessageDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
titleId = R.string.clear_shader_cache,
|
||||
|
|
@ -271,6 +322,7 @@ class GamePropertiesFragment : Fragment() {
|
|||
).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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>? = null,
|
||||
val action: () -> Unit
|
||||
val action: () -> Unit,
|
||||
val secondaryActions: List<SubMenuPropertySecondaryAction>? = 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,
|
||||
|
|
|
|||
|
|
@ -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 '/'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@
|
|||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
@ -69,4 +74,22 @@
|
|||
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -675,6 +675,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>
|
||||
|
|
@ -789,6 +790,10 @@
|
|||
<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 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 -->
|
||||
<string name="loader_error_encrypted">Your ROM is encrypted</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue