From e1efec5a24208df281d02c3a6d7a323ddfe2a13b Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 12 Jul 2025 13:02:48 +0200 Subject: [PATCH 1/7] Support loading custom game settings via intent This commit introduces the ability to launch games with custom configurations supplied via an Android intent. This allows external applications to provide specific settings for a game at launch time. Key changes include: * **`CustomSettingsHandler.kt`**: A new class responsible for: * Processing incoming intents with custom settings. * Finding the target game in the user's library by its title ID. * Writing the custom settings to a per-game INI file (`config/custom/.ini`). * Handling potential conflicts if a custom configuration already exists, prompting the user to overwrite or cancel. * Integrating with `DriverResolver` to check for and handle required GPU drivers specified in the custom settings. * Initializing the native per-game configuration. * **`DriverResolver.kt`**: A new utility class for managing GPU drivers specified in custom settings: * Extracts the driver path from the custom settings INI content. * Checks if the required driver exists locally. * If not found locally, searches for the driver in predefined GitHub repositories (Mr. Purple Turnip, GameHub Adreno 8xx, KIMCHI Turnip, Weab-Chan Freedreno). * Prompts the user to download and install the missing driver if found online. * Handles automatic download and installation of drivers using `DriverViewModel`. * Notifies the user if a required driver cannot be found or installed. * **`AndroidManifest.xml`**: * Added a new intent filter for the action `dev.eden.eden_emulator.LAUNCH_WITH_CUSTOM_CONFIG` to `EmulationActivity`. This allows the app to respond to custom settings intents. * **`EmulationFragment.kt`**: * Modified `onCreate` to detect and handle the new custom settings intent. * If a custom settings intent is received: * It uses `CustomSettingsHandler.applyCustomSettingsWithDriverCheck` to process the settings asynchronously. This allows for driver checks and user interaction (e.g., overwrite confirmation, driver installation). * Displays appropriate error messages via `Toast` if custom settings processing fails (e.g., game not found, driver issues). * The game is then launched with the applied custom settings. * If a regular file intent or navigation arguments are used, the existing logic for loading game configurations (including custom per-game configs) is retained. * Ensures that per-game configurations are correctly loaded or unloaded based on how the game is launched. --- src/android/app/src/main/AndroidManifest.xml | 6 +- .../yuzu_emu/fragments/EmulationFragment.kt | 112 +++++- .../yuzu_emu/utils/CustomSettingsHandler.kt | 222 +++++++++++ .../org/yuzu/yuzu_emu/utils/DriverResolver.kt | 362 ++++++++++++++++++ 4 files changed, 690 insertions(+), 12 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 353c2f722d..198838f6ef 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -85,6 +85,10 @@ SPDX-License-Identifier: GPL-3.0-or-later + + + + @@ -100,4 +104,4 @@ SPDX-License-Identifier: GPL-3.0-or-later - \ No newline at end of file + diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 6cce31a4eb..200af1587f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -44,6 +44,7 @@ import androidx.core.view.updateLayoutParams import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout.DrawerListener import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs @@ -81,6 +82,10 @@ import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.ViewUtils import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible import org.yuzu.yuzu_emu.utils.collect +import org.yuzu.yuzu_emu.utils.CustomSettingsHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.File class EmulationFragment : Fragment(), SurfaceHolder.Callback { @@ -94,6 +99,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var gpuDriver: String private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! private val args by navArgs() @@ -108,6 +114,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var gpuModel: String private lateinit var fwVersion: String + private var intentGame: Game? = null + private var isCustomSettingsIntent = false + override fun onAttach(context: Context) { super.onAttach(context) if (context is EmulationActivity) { @@ -125,9 +134,70 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { super.onCreate(savedInstanceState) updateOrientation() - val intentUri: Uri? = requireActivity().intent.data - var intentGame: Game? = null - if (intentUri != null) { + val intent = requireActivity().intent + val intentUri: Uri? = intent.data + intentGame = null + isCustomSettingsIntent = false + + if (intent.action == CustomSettingsHandler.CUSTOM_CONFIG_ACTION) { + val titleId = intent.getStringExtra(CustomSettingsHandler.EXTRA_TITLE_ID) + val customSettings = intent.getStringExtra(CustomSettingsHandler.EXTRA_CUSTOM_SETTINGS) + + if (titleId != null && customSettings != null) { + Log.info("[EmulationFragment] Received custom settings intent for title: $titleId") + + // Handle custom settings asynchronously to allow for driver checking/installation + CoroutineScope(Dispatchers.Main).launch { + try { + intentGame = CustomSettingsHandler.applyCustomSettingsWithDriverCheck( + titleId, + customSettings, + requireContext(), + requireActivity() as? FragmentActivity, + driverViewModel + ) + + if (intentGame == null) { + Log.error("[EmulationFragment] Custom settings processing failed for title ID: $titleId") + Toast.makeText( + requireContext(), + "Failed to apply custom settings. This could be due to:\n• Game not found in library\n• User cancelled configuration overwrite\n• Driver installation failed\n• Missing required drivers", + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + return@launch + } + + isCustomSettingsIntent = true + + // Continue with game setup + finishGameSetup() + + } catch (e: Exception) { + Log.error("[EmulationFragment] Error processing custom settings: ${e.message}") + Toast.makeText( + requireContext(), + "Error processing custom settings: ${e.message}", + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + } + } + + // Return early to prevent synchronous continuation + return + } else { + Log.error("[EmulationFragment] Custom settings intent missing required extras") + Toast.makeText( + requireContext(), + "Invalid custom settings data", + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + } else if (intentUri != null) { + // Handle regular file intent intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { GameHelper.getGame(requireActivity().intent.data!!, false) } else { @@ -135,6 +205,15 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + if (!isCustomSettingsIntent) { + finishGameSetup() + } + } + + /** + * Complete the game setup process (extracted for async custom settings handling) + */ + private fun finishGameSetup() { try { game = if (args.game != null) { args.game!! @@ -151,8 +230,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { return } - // Always load custom settings when launching a game from an intent - if (args.custom || intentGame != null) { + // Handle configuration loading + if (isCustomSettingsIntent) { + // Custom settings already applied by CustomSettingsHandler + Log.info("[EmulationFragment] Using custom settings from intent") + } else if (args.custom || intentGame != null) { + // Always load custom settings when launching a game from an intent SettingsFile.loadCustomConfig(game) NativeConfig.unloadPerGameConfig() } else { @@ -162,13 +245,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // Install the selected driver asynchronously as the game starts driverViewModel.onLaunchGame() - // So this fragment doesn't restart on configuration changes; i.e. rotation. - retainInstance = true + // Initialize emulation state (ViewModels handle state retention now) emulationState = EmulationState(game.path) { return@EmulationState driverViewModel.isInteractionAllowed.value } } + /** * Initialize the UI and start emulation in here. */ @@ -634,7 +717,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { val batteryTemp = getBatteryTemperature() when (IntSetting.BAT_TEMPERATURE_UNIT.getInt(needsGlobal)) { 0 -> sb.append(String.format("%.1f°C", batteryTemp)) - 1 -> sb.append(String.format("%.1f°F", celsiusToFahrenheit(batteryTemp))) + 1 -> sb.append( + String.format( + "%.1f°F", + celsiusToFahrenheit(batteryTemp) + ) + ) } } @@ -643,8 +731,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { val battery: BatteryManager = requireContext().getSystemService(Context.BATTERY_SERVICE) as BatteryManager - val batteryIntent = requireContext().registerReceiver(null, - IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val batteryIntent = requireContext().registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED) + ) val capacity = battery.getIntProperty(BATTERY_PROPERTY_CAPACITY) val nowUAmps = battery.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW) @@ -653,7 +743,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL + status == BatteryManager.BATTERY_STATUS_FULL if (isCharging) { sb.append(" ${getString(R.string.charging)}") diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt new file mode 100644 index 0000000000..030a328f20 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt @@ -0,0 +1,222 @@ +// 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 + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.Game +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object CustomSettingsHandler { + const val CUSTOM_CONFIG_ACTION = "dev.eden.eden_emulator.LAUNCH_WITH_CUSTOM_CONFIG" + const val EXTRA_TITLE_ID = "title_id" + const val EXTRA_CUSTOM_SETTINGS = "custom_settings" + + /** + * Apply custom settings from a string instead of loading from file + * @param titleId The game title ID (16-digit hex string) + * @param customSettings The complete INI file content as string + * @param context Application context + * @return Game object created from title ID, or null if not found + */ + fun applyCustomSettings(titleId: String, customSettings: String, context: Context): Game? { + // For synchronous calls without driver checking + Log.info("[CustomSettingsHandler] Applying custom settings for title ID: $titleId") + // Find the game by title ID + val game = findGameByTitleId(titleId, context) + if (game == null) { + Log.error("[CustomSettingsHandler] Game not found for title ID: $titleId") + return null + } + + // Check if config already exists - this should be handled by the caller + val configFile = getConfigFile(titleId) + if (configFile.exists()) { + Log.warning("[CustomSettingsHandler] Config file already exists for title ID: $titleId") + // The caller should have already asked the user about overwriting + } + + // Write the config file + if (!writeConfigFile(titleId, customSettings)) { + Log.error("[CustomSettingsHandler] Failed to write config file") + return null + } + + // Initialize per-game config + try { + NativeConfig.initializePerGameConfig(game.programId, configFile.nameWithoutExtension) + Log.info("[CustomSettingsHandler] Successfully applied custom settings") + return game + } catch (e: Exception) { + Log.error("[CustomSettingsHandler] Failed to apply custom settings: ${e.message}") + return null + } + } + + /** + * Apply custom settings with automatic driver checking and installation + * @param titleId The game title ID (16-digit hex string) + * @param customSettings The complete INI file content as string + * @param context Application context + * @param activity Fragment activity for driver installation dialogs (optional) + * @param driverViewModel DriverViewModel for driver management (optional) + * @return Game object created from title ID, or null if not found + */ + suspend fun applyCustomSettingsWithDriverCheck( + titleId: String, + customSettings: String, + context: Context, + activity: FragmentActivity?, + driverViewModel: DriverViewModel? + ): Game? { + Log.info("[CustomSettingsHandler] Applying custom settings for title ID: $titleId") + // Find the game by title ID + val game = findGameByTitleId(titleId, context) + if (game == null) { + Log.error("[CustomSettingsHandler] Game not found for title ID: $titleId") + // This will be handled by the caller to show appropriate error message + return null + } + + // Check if config already exists + val configFile = getConfigFile(titleId) + if (configFile.exists() && activity != null) { + Log.info("[CustomSettingsHandler] Config file already exists, asking user for confirmation") + val shouldOverwrite = askUserToOverwriteConfig(activity, game.title) + if (!shouldOverwrite) { + Log.info("[CustomSettingsHandler] User chose not to overwrite existing config") + return null + } + } + + // Check for driver requirements if activity and driverViewModel are provided + if (activity != null && driverViewModel != null) { + val driverPath = DriverResolver.extractDriverPath(customSettings) + if (driverPath != null) { + Log.info("[CustomSettingsHandler] Custom settings specify driver: $driverPath") + val driverExists = DriverResolver.ensureDriverExists(driverPath, activity, driverViewModel) + if (!driverExists) { + Log.error("[CustomSettingsHandler] Required driver not available: $driverPath") + // Don't write config if driver installation failed + return null + } + } + } + + // Only write the config file after all checks pass + if (!writeConfigFile(titleId, customSettings)) { + Log.error("[CustomSettingsHandler] Failed to write config file") + return null + } + + // Initialize per-game config + try { + NativeConfig.initializePerGameConfig(game.programId, configFile.nameWithoutExtension) + Log.info("[CustomSettingsHandler] Successfully applied custom settings") + return game + } catch (e: Exception) { + Log.error("[CustomSettingsHandler] Failed to apply custom settings: ${e.message}") + return null + } + } + + /** + * Find a game by its title ID in the user's game library + */ + private fun findGameByTitleId(titleId: String, context: Context): Game? { + Log.info("[CustomSettingsHandler] Searching for game with title ID: $titleId") + // Convert hex title ID to decimal for comparison with programId + val programIdDecimal = try { + titleId.toLong(16).toString() + } catch (e: NumberFormatException) { + Log.error("[CustomSettingsHandler] Invalid title ID format: $titleId") + return null + } + + // Expected hex format with "0" prefix + val expectedHex = "0${titleId.uppercase()}" + // First check cached games for fast lookup + GameHelper.cachedGameList.find { game -> + game.programId == programIdDecimal || + game.programIdHex.equals(expectedHex, ignoreCase = true) + }?.let { foundGame -> + Log.info("[CustomSettingsHandler] Found game in cache: ${foundGame.title}") + return foundGame + } + // If not in cache, perform full game library scan + Log.info("[CustomSettingsHandler] Game not in cache, scanning full library...") + val allGames = GameHelper.getGames() + val foundGame = allGames.find { game -> + game.programId == programIdDecimal || + game.programIdHex.equals(expectedHex, ignoreCase = true) + } + if (foundGame != null) { + Log.info("[CustomSettingsHandler] Found game: ${foundGame.title} at ${foundGame.path}") + } else { + Log.warning("[CustomSettingsHandler] No game found for title ID: $titleId") + } + return foundGame + } + + /** + * Get the config file path for a title ID + */ + private fun getConfigFile(titleId: String): File { + val configDir = File(DirectoryInitialization.userDirectory, "config/custom") + return File(configDir, "$titleId.ini") + } + + /** + * Write the config file with the custom settings + */ + private fun writeConfigFile(titleId: String, customSettings: String): Boolean { + return try { + val configDir = File(DirectoryInitialization.userDirectory, "config/custom") + if (!configDir.exists()) { + configDir.mkdirs() + } + + val configFile = File(configDir, "$titleId.ini") + configFile.writeText(customSettings) + + Log.info("[CustomSettingsHandler] Wrote config file: ${configFile.absolutePath}") + true + } catch (e: Exception) { + Log.error("[CustomSettingsHandler] Failed to write config file: ${e.message}") + false + } + } + + /** + * Ask user if they want to overwrite existing configuration + */ + private suspend fun askUserToOverwriteConfig(activity: FragmentActivity, gameTitle: String): Boolean { + return suspendCoroutine { continuation -> + activity.runOnUiThread { + MaterialAlertDialogBuilder(activity) + .setTitle("Configuration Already Exists") + .setMessage( + "Custom settings already exist for '$gameTitle'.\n\n" + + "Do you want to overwrite the existing configuration?\n\n" + + "This action cannot be undone." + ) + .setPositiveButton("Overwrite") { _, _ -> + continuation.resume(true) + } + .setNegativeButton("Cancel") { _, _ -> + continuation.resume(false) + } + .setCancelable(false) + .show() + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt new file mode 100644 index 0000000000..386a84e7d9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt @@ -0,0 +1,362 @@ +// 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 + +package org.yuzu.yuzu_emu.utils + +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.yuzu.yuzu_emu.fragments.DriverFetcherFragment +import org.yuzu.yuzu_emu.fragments.DriverFetcherFragment.SortMode +import org.yuzu.yuzu_emu.model.DriverViewModel +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object DriverResolver { + private val client = OkHttpClient() + + // Mirror of the repositories from DriverFetcherFragment + private val repoList = listOf( + DriverRepo("Mr. Purple Turnip", "MrPurple666/purple-turnip", 0), + DriverRepo("GameHub Adreno 8xx", "crueter/GameHub-8Elite-Drivers", 1), + DriverRepo("KIMCHI Turnip", "K11MCH1/AdrenoToolsDrivers", 2, true), + DriverRepo("Weab-Chan Freedreno", "Weab-chan/freedreno_turnip-CI", 3), + ) + + private data class DriverRepo( + val name: String, + val path: String, + val sort: Int, + val useTagName: Boolean = false + ) + + /** + * Extract driver path from custom settings INI content + */ + fun extractDriverPath(customSettings: String): String? { + val lines = customSettings.lines() + var inGpuDriverSection = false + + for (line in lines) { + val trimmed = line.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + inGpuDriverSection = trimmed == "[GpuDriver]" + continue + } + + if (inGpuDriverSection && trimmed.startsWith("driver_path=")) { + return trimmed.substringAfter("driver_path=") + } + } + + return null + } + + /** + * Check if a driver exists and handle missing drivers + */ + suspend fun ensureDriverExists( + driverPath: String, + activity: FragmentActivity, + driverViewModel: DriverViewModel + ): Boolean { + Log.info("[DriverResolver] Checking driver path: $driverPath") + + val driverFile = File(driverPath) + if (driverFile.exists()) { + Log.info("[DriverResolver] Driver exists at: $driverPath") + return true + } + + Log.warning("[DriverResolver] Driver not found: $driverPath") + + // Extract driver name from path + val driverName = extractDriverNameFromPath(driverPath) + if (driverName == null) { + Log.error("[DriverResolver] Could not extract driver name from path") + return false + } + + Log.info("[DriverResolver] Searching for downloadable driver: $driverName") + + // Check if driver exists locally with different path + val localDriver = findLocalDriver(driverName) + if (localDriver != null) { + Log.info("[DriverResolver] Found local driver: ${localDriver.first}") + // The game can use this local driver, no need to download + return true + } + + // Search for downloadable driver + val downloadableDriver = findDownloadableDriver(driverName) + if (downloadableDriver != null) { + Log.info("[DriverResolver] Found downloadable driver: ${downloadableDriver.name}") + + val shouldInstall = askUserToInstallDriver(activity, downloadableDriver.name) + if (shouldInstall) { + return downloadAndInstallDriver(activity, downloadableDriver, driverViewModel) + } + } else { + Log.warning("[DriverResolver] No downloadable driver found for: $driverName") + showDriverNotFoundDialog(activity, driverName) + } + + return false + } + + /** + * Extract driver name from full path + */ + private fun extractDriverNameFromPath(driverPath: String): String? { + val file = File(driverPath) + val fileName = file.name + + // Remove .zip extension and extract meaningful name + if (fileName.endsWith(".zip")) { + return fileName.substring(0, fileName.length - 4) + } + + return fileName + } + + /** + * Find driver in local storage by name matching + */ + private fun findLocalDriver(driverName: String): Pair? { + val availableDrivers = GpuDriverHelper.getDrivers() + + // Try exact match first + availableDrivers.find { (_, metadata) -> + metadata.name?.contains(driverName, ignoreCase = true) == true + }?.let { return it } + + // Try partial match + availableDrivers.find { (path, metadata) -> + path.contains(driverName, ignoreCase = true) || + metadata.name?.contains( + extractKeywords(driverName).first(), + ignoreCase = true + ) == true + }?.let { return it } + + return null + } + + /** + * Extract keywords from driver name for matching + */ + private fun extractKeywords(driverName: String): List { + val keywords = mutableListOf() + + // Common driver patterns + when { + driverName.contains("turnip", ignoreCase = true) -> keywords.add("turnip") + driverName.contains("purple", ignoreCase = true) -> keywords.add("purple") + driverName.contains("kimchi", ignoreCase = true) -> keywords.add("kimchi") + driverName.contains("freedreno", ignoreCase = true) -> keywords.add("freedreno") + driverName.contains("gamehub", ignoreCase = true) -> keywords.add("gamehub") + } + + // Version patterns + Regex("v?\\d+\\.\\d+\\.\\d+").find(driverName)?.value?.let { keywords.add(it) } + + if (keywords.isEmpty()) { + keywords.add(driverName) + } + + return keywords + } + + /** + * Find downloadable driver that matches the required driver + */ + private suspend fun findDownloadableDriver(driverName: String): DriverFetcherFragment.Artifact? { + val keywords = extractKeywords(driverName) + + for (repo in repoList) { + // Check if this repo is relevant based on driver name + val isRelevant = keywords.any { keyword -> + repo.name.contains(keyword, ignoreCase = true) || + keyword.contains(repo.name.split(" ").first(), ignoreCase = true) + } + + if (!isRelevant) continue + + try { + val releases = fetchReleases(repo) + val latestRelease = releases.firstOrNull { !it.prerelease } + + latestRelease?.artifacts?.forEach { artifact -> + if (matchesDriverName(artifact.name, driverName, keywords)) { + return artifact + } + } + } catch (e: Exception) { + Log.error("[DriverResolver] Failed to fetch releases for ${repo.name}: ${e.message}") + } + } + + return null + } + + /** + * Check if artifact name matches the required driver + */ + private fun matchesDriverName( + artifactName: String, + requiredName: String, + keywords: List + ): Boolean { + // Exact match + if (artifactName.equals(requiredName, ignoreCase = true)) return true + + // Keyword matching + return keywords.any { keyword -> + artifactName.contains(keyword, ignoreCase = true) + } + } + + /** + * Fetch releases from GitHub repo + */ + private suspend fun fetchReleases(repo: DriverRepo): List { + return withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("https://api.github.com/repos/${repo.path}/releases") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Failed to fetch releases: ${response.code}") + } + + val body = response.body?.string() ?: throw IOException("Empty response") + DriverFetcherFragment.Release.fromJsonArray(body, repo.useTagName, SortMode.Default) + } + } + } + + /** + * Ask user if they want to install the missing driver + */ + private suspend fun askUserToInstallDriver( + activity: FragmentActivity, + driverName: String + ): Boolean { + return suspendCoroutine { continuation -> + activity.runOnUiThread { + MaterialAlertDialogBuilder(activity) + .setTitle("Missing GPU Driver") + .setMessage( + "The custom settings require the GPU driver '$driverName' which is not installed.\n\n" + + "Would you like to download and install it automatically?" + ) + .setPositiveButton("Install") { _, _ -> + continuation.resume(true) + } + .setNegativeButton("Cancel") { _, _ -> + continuation.resume(false) + } + .setCancelable(false) + .show() + } + } + } + + /** + * Download and install driver automatically + */ + private suspend fun downloadAndInstallDriver( + activity: FragmentActivity, + artifact: DriverFetcherFragment.Artifact, + driverViewModel: DriverViewModel + ): Boolean { + return try { + Log.info("[DriverResolver] Downloading driver: ${artifact.name}") + + val cacheDir = + activity.externalCacheDir ?: throw IOException("Cache directory not available") + cacheDir.mkdirs() + + val file = File(cacheDir, artifact.name) + + // Download the driver + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(artifact.url) + .header("Accept", "application/octet-stream") + .build() + + client.newBuilder() + .followRedirects(true) + .followSslRedirects(true) + .build() + .newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Download failed: ${response.code}") + } + + response.body?.byteStream()?.use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } ?: throw IOException("Empty response body") + } + } + + if (file.length() == 0L) { + throw IOException("Downloaded file is empty") + } + + // Install the driver on main thread + withContext(Dispatchers.Main) { + val driverData = GpuDriverHelper.getMetadataFromZip(file) + val driverPath = "${GpuDriverHelper.driverStoragePath}${file.name}" + + if (GpuDriverHelper.copyDriverToInternalStorage(file.toUri())) { + driverViewModel.onDriverAdded(Pair(driverPath, driverData)) + Log.info("[DriverResolver] Successfully installed driver: ${driverData.name}") + true + } else { + throw IOException("Failed to install driver") + } + } + } catch (e: Exception) { + Log.error("[DriverResolver] Failed to download/install driver: ${e.message}") + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(activity) + .setTitle("Installation Failed") + .setMessage("Failed to download and install the driver: ${e.message}") + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() + } + false + } + } + + /** + * Show dialog when driver cannot be found + */ + private fun showDriverNotFoundDialog(activity: FragmentActivity, driverName: String) { + activity.runOnUiThread { + MaterialAlertDialogBuilder(activity) + .setTitle("Driver Not Available") + .setMessage( + "The required GPU driver '$driverName' is not available for automatic download.\n\n" + + "Please manually install the driver or launch the game with default settings." + ) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() + } + } +} From e00abd0281216a85a73150ce98f8001015761318 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 12 Jul 2025 18:25:28 +0200 Subject: [PATCH 2/7] chore: removed unnecessary typecasting --- .../org/yuzu/yuzu_emu/fragments/EmulationFragment.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 200af1587f..9b275972b4 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -153,7 +153,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { titleId, customSettings, requireContext(), - requireActivity() as? FragmentActivity, + requireActivity(), driverViewModel ) @@ -170,7 +170,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { isCustomSettingsIntent = true - // Continue with game setup finishGameSetup() } catch (e: Exception) { @@ -184,7 +183,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } - // Return early to prevent synchronous continuation return } else { Log.error("[EmulationFragment] Custom settings intent missing required extras") @@ -205,9 +203,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } - if (!isCustomSettingsIntent) { - finishGameSetup() - } + if (!isCustomSettingsIntent) finishGameSetup() } /** @@ -221,6 +217,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { intentGame!! } } catch (e: NullPointerException) { + Log.error("[EmulationFragment] No game found in arguments or intent: ${e.message}") Toast.makeText( requireContext(), R.string.no_game_present, From ab50b5cc9a451e5afd970ed3d9cfcc3550358122 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 12 Jul 2025 20:46:33 +0200 Subject: [PATCH 3/7] fix: deprecating warning for getLayoutDirection and LAYOUT_DIRECTION_LTR --- .../main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 9b275972b4..0d6b744077 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -1234,7 +1234,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) var left = 0 var right = 0 - if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + if (v.layoutDirection == View.LAYOUT_DIRECTION_LTR) { left = cutInsets.left } else { right = cutInsets.right From 9adb75b0dc4a5f5f08f20aa96994eb85549046e6 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 12 Jul 2025 20:47:37 +0200 Subject: [PATCH 4/7] fix: refactor EmulationFragment *handler callbacks using nullable Runnables * Remove unused `cpuBackend` and `gpuDriver` variables. * Use lambda syntax for `Slider.OnChangeListener`. * Remove unused imports. --- .../yuzu_emu/fragments/EmulationFragment.kt | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 0d6b744077..36870e9768 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -44,7 +44,6 @@ import androidx.core.view.updateLayoutParams import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout.DrawerListener import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs @@ -53,7 +52,6 @@ import androidx.window.layout.WindowInfoTracker import androidx.window.layout.WindowLayoutInfo import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider import com.google.android.material.textview.MaterialTextView import org.yuzu.yuzu_emu.HomeNavigationDirections import org.yuzu.yuzu_emu.NativeLibrary @@ -95,9 +93,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var perfStatsUpdater: (() -> Unit)? = null private var socUpdater: (() -> Unit)? = null - private lateinit var cpuBackend: String - private lateinit var gpuDriver: String - private var _binding: FragmentEmulationBinding? = null private val binding get() = _binding!! @@ -117,6 +112,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var intentGame: Game? = null private var isCustomSettingsIntent = false + private var perfStatsRunnable: Runnable? = null + private var socRunnable: Runnable? = null + override fun onAttach(context: Context) { super.onAttach(context) if (context is EmulationActivity) { @@ -536,16 +534,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { ViewUtils.showView(binding.loadingIndicator) } } - emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { - if (it && emulationViewModel.programChanged.value != -1) { - if (perfStatsUpdater != null) { - perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) - } - - if (socUpdater != null) { - socUpdateHandler.removeCallbacks(socUpdater!!) - } + emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { stopped -> + if (stopped && emulationViewModel.programChanged.value != -1) { + perfStatsRunnable?.let { runnable -> perfStatsUpdateHandler.removeCallbacks(runnable) } + socRunnable?.let { runnable -> socUpdateHandler.removeCallbacks(runnable) } emulationState.changeProgram(emulationViewModel.programChanged.value) emulationViewModel.setProgramChanged(-1) emulationViewModel.setEmulationStopped(false) @@ -765,13 +758,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { binding.showStatsOverlayText.text = sb.toString() } - perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) + perfStatsUpdateHandler.postDelayed(perfStatsRunnable!!, 800) } - perfStatsUpdateHandler.post(perfStatsUpdater!!) + perfStatsRunnable = Runnable { perfStatsUpdater?.invoke() } + perfStatsUpdateHandler.post(perfStatsRunnable!!) } else { - if (perfStatsUpdater != null) { - perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) - } + perfStatsRunnable?.let { perfStatsUpdateHandler.removeCallbacks(it) } } } @@ -884,13 +876,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } - socUpdateHandler.postDelayed(socUpdater!!, 1000) + socUpdateHandler.postDelayed(socRunnable!!, 1000) } - socUpdateHandler.post(socUpdater!!) + socRunnable = Runnable { socUpdater?.invoke() } + socUpdateHandler.post(socRunnable!!) } else { - if (socUpdater != null) { - socUpdateHandler.removeCallbacks(socUpdater!!) - } + socRunnable?.let { socUpdateHandler.removeCallbacks(it) } } } @@ -1183,22 +1174,18 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { inputScaleSlider.apply { valueTo = 150F value = IntSetting.OVERLAY_SCALE.getInt().toFloat() - addOnChangeListener( - Slider.OnChangeListener { _, value, _ -> - inputScaleValue.text = "${value.toInt()}%" - setControlScale(value.toInt()) - } - ) + addOnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + } } inputOpacitySlider.apply { valueTo = 100F value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() - addOnChangeListener( - Slider.OnChangeListener { _, value, _ -> - inputOpacityValue.text = "${value.toInt()}%" - setControlOpacity(value.toInt()) - } - ) + addOnChangeListener { _, value, _ -> + inputOpacityValue.text = "${value.toInt()}%" + setControlOpacity(value.toInt()) + } } inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" From 1eeab7e19ea3090e1edae7a4c187eadc9d3e0808 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 12 Jul 2025 21:11:34 +0200 Subject: [PATCH 5/7] fix: suppress warning for predictive back gesture * src/android/app/src/main/AndroidManifest.xml: Set targetApi to 33 and enable onBackInvokedCallback to support the Android 13 predictive back gesture. Also, add the tools namespace. --- src/android/app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 198838f6ef..d31deaa355 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ SPDX-FileCopyrightText: Eden Emulator Project SPDX-License-Identifier: GPL-3.0-or-later --> - + @@ -42,6 +42,7 @@ SPDX-License-Identifier: GPL-3.0-or-later android:banner="@drawable/tv_banner" android:fullBackupContent="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules_api_31" + tools:targetApi="33" android:enableOnBackInvokedCallback="true"> From e2f2cfa4760307e37c15eaa97d43d7309cff0bef Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 13 Jul 2025 07:31:22 +0200 Subject: [PATCH 6/7] refactor: extract and streamline EmuReady intent handling * Moved custom settings logic from `onCreate` to `handleEmuReadyIntent` for better readability. * Added `showLaunchConfirmationDialog` to confirm game launch with or without custom settings. * Updated `CustomSettingsHandler.findGameByTitleId` to show user feedback via `Toast`. * Ensured improved separation of concerns and reusable methods. --- .../yuzu_emu/fragments/EmulationFragment.kt | 167 ++++++++++++------ .../yuzu_emu/utils/CustomSettingsHandler.kt | 5 +- 2 files changed, 117 insertions(+), 55 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 36870e9768..a4c86b94c7 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -84,6 +84,8 @@ import org.yuzu.yuzu_emu.utils.CustomSettingsHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine import java.io.File class EmulationFragment : Fragment(), SurfaceHolder.Callback { @@ -138,60 +140,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { isCustomSettingsIntent = false if (intent.action == CustomSettingsHandler.CUSTOM_CONFIG_ACTION) { - val titleId = intent.getStringExtra(CustomSettingsHandler.EXTRA_TITLE_ID) - val customSettings = intent.getStringExtra(CustomSettingsHandler.EXTRA_CUSTOM_SETTINGS) - - if (titleId != null && customSettings != null) { - Log.info("[EmulationFragment] Received custom settings intent for title: $titleId") - - // Handle custom settings asynchronously to allow for driver checking/installation - CoroutineScope(Dispatchers.Main).launch { - try { - intentGame = CustomSettingsHandler.applyCustomSettingsWithDriverCheck( - titleId, - customSettings, - requireContext(), - requireActivity(), - driverViewModel - ) - - if (intentGame == null) { - Log.error("[EmulationFragment] Custom settings processing failed for title ID: $titleId") - Toast.makeText( - requireContext(), - "Failed to apply custom settings. This could be due to:\n• Game not found in library\n• User cancelled configuration overwrite\n• Driver installation failed\n• Missing required drivers", - Toast.LENGTH_LONG - ).show() - requireActivity().finish() - return@launch - } - - isCustomSettingsIntent = true - - finishGameSetup() - - } catch (e: Exception) { - Log.error("[EmulationFragment] Error processing custom settings: ${e.message}") - Toast.makeText( - requireContext(), - "Error processing custom settings: ${e.message}", - Toast.LENGTH_LONG - ).show() - requireActivity().finish() - } - } - - return - } else { - Log.error("[EmulationFragment] Custom settings intent missing required extras") - Toast.makeText( - requireContext(), - "Invalid custom settings data", - Toast.LENGTH_SHORT - ).show() - requireActivity().finish() - return - } + handleEmuReadyIntent(intent) + return } else if (intentUri != null) { // Handle regular file intent intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { @@ -246,6 +196,115 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + /** + * Handle EmuReady intent for launching games with or without custom settings + */ + private fun handleEmuReadyIntent(intent: Intent) { + val titleId = intent.getStringExtra(CustomSettingsHandler.EXTRA_TITLE_ID) + val customSettings = intent.getStringExtra(CustomSettingsHandler.EXTRA_CUSTOM_SETTINGS) + + if (titleId != null) { + Log.info("[EmulationFragment] Received EmuReady intent for title: $titleId") + + CoroutineScope(Dispatchers.Main).launch { + try { + // Find the game first to get the title for confirmation + val foundGame = CustomSettingsHandler.findGameByTitleId(titleId, requireContext()) + if (foundGame == null) { + Log.error("[EmulationFragment] Game not found for title ID: $titleId") + Toast.makeText( + requireContext(), + "Game not found in library for title ID: $titleId", + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + return@launch + } + + // Show confirmation dialog + val shouldLaunch = showLaunchConfirmationDialog(foundGame.title, customSettings != null) + if (!shouldLaunch) { + Log.info("[EmulationFragment] User cancelled EmuReady launch") + requireActivity().finish() + return@launch + } + + if (customSettings != null) { + // Handle custom settings launch + intentGame = CustomSettingsHandler.applyCustomSettingsWithDriverCheck( + titleId, + customSettings, + requireContext(), + requireActivity(), + driverViewModel + ) + + if (intentGame == null) { + Log.error("[EmulationFragment] Custom settings processing failed for title ID: $titleId") + Toast.makeText( + requireContext(), + "Failed to apply custom settings. This could be due to:\n• User cancelled configuration overwrite\n• Driver installation failed\n• Missing required drivers", + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + return@launch + } + + isCustomSettingsIntent = true + } else { + // Handle title-only launch (no custom settings) + intentGame = foundGame + } + + finishGameSetup() + + } catch (e: Exception) { + Log.error("[EmulationFragment] Error processing EmuReady intent: ${e.message}") + Toast.makeText( + requireContext(), + "Error processing EmuReady request: ${e.message}", + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + } + } + } else { + Log.error("[EmulationFragment] EmuReady intent missing title_id") + Toast.makeText( + requireContext(), + "Invalid EmuReady request: missing title ID", + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + } + } + + /** + * Show confirmation dialog for EmuReady game launches + */ + private suspend fun showLaunchConfirmationDialog(gameTitle: String, hasCustomSettings: Boolean): Boolean { + return suspendCoroutine { continuation -> + requireActivity().runOnUiThread { + val message = if (hasCustomSettings) { + "EmuReady wants to launch \"$gameTitle\" with custom settings.\n\nDo you want to continue?" + } else { + "EmuReady wants to launch \"$gameTitle\".\n\nDo you want to continue?" + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Launch Game") + .setMessage(message) + .setPositiveButton("Launch") { _, _ -> + continuation.resume(true) + } + .setNegativeButton("Cancel") { _, _ -> + continuation.resume(false) + } + .setCancelable(false) + .show() + } + } + } /** * Initialize the UI and start emulation in here. diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt index 030a328f20..cb0b505ff9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt @@ -7,6 +7,7 @@ package org.yuzu.yuzu_emu.utils import android.content.Context +import android.widget.Toast import androidx.fragment.app.FragmentActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.yuzu.yuzu_emu.model.DriverViewModel @@ -131,7 +132,7 @@ object CustomSettingsHandler { /** * Find a game by its title ID in the user's game library */ - private fun findGameByTitleId(titleId: String, context: Context): Game? { + fun findGameByTitleId(titleId: String, context: Context): Game? { Log.info("[CustomSettingsHandler] Searching for game with title ID: $titleId") // Convert hex title ID to decimal for comparison with programId val programIdDecimal = try { @@ -160,8 +161,10 @@ object CustomSettingsHandler { } if (foundGame != null) { Log.info("[CustomSettingsHandler] Found game: ${foundGame.title} at ${foundGame.path}") + Toast.makeText(context, "Found game: ${foundGame.title}", Toast.LENGTH_SHORT).show() } else { Log.warning("[CustomSettingsHandler] No game found for title ID: $titleId") + Toast.makeText(context, "Game not found for title ID: $titleId", Toast.LENGTH_SHORT).show() } return foundGame } From d76218baa1c9efadf03b30cfb6614dca6d9894bc Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 20 Jul 2025 18:11:55 +0200 Subject: [PATCH 7/7] Add user feedback with Toast messages for custom settings and driver handling * Introduced Toast messages across `CustomSettingsHandler`, `DriverResolver`, and `EmulationFragment` to improve user interaction and feedback for key operations. * Enhanced error handling and confirmation dialogs, including options to launch with default settings when custom settings fail. --- .../yuzu_emu/fragments/EmulationFragment.kt | 151 +++++++++++++++--- .../yuzu_emu/utils/CustomSettingsHandler.kt | 11 +- .../org/yuzu/yuzu_emu/utils/DriverResolver.kt | 3 + 3 files changed, 143 insertions(+), 22 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index a4c86b94c7..2834c36add 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -151,7 +151,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } - if (!isCustomSettingsIntent) finishGameSetup() + // For non-EmuReady intents, finish game setup immediately + // EmuReady intents handle setup asynchronously in handleEmuReadyIntent() + if (!isCustomSettingsIntent) { + finishGameSetup() + } } /** @@ -173,18 +177,52 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { ).show() requireActivity().finish() return + } catch (e: Exception) { + Log.error("[EmulationFragment] Error during game setup: ${e.message}") + Toast.makeText( + requireContext(), + "Setup error: ${e.message?.take(30) ?: "Unknown"}", + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return } // Handle configuration loading - if (isCustomSettingsIntent) { - // Custom settings already applied by CustomSettingsHandler - Log.info("[EmulationFragment] Using custom settings from intent") - } else if (args.custom || intentGame != null) { - // Always load custom settings when launching a game from an intent - SettingsFile.loadCustomConfig(game) - NativeConfig.unloadPerGameConfig() - } else { - NativeConfig.reloadGlobalConfig() + try { + if (isCustomSettingsIntent) { + // Custom settings already applied by CustomSettingsHandler + Log.info("[EmulationFragment] Using custom settings from intent") + } else if (args.custom) { + // Load custom settings when explicitly requested via args + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + Log.info("[EmulationFragment] Loading custom settings for ${game.title}") + } else if (intentGame != null) { + // For intent games, check if custom settings exist and load them, otherwise use global + val customConfigFile = SettingsFile.getCustomSettingsFile(game) + if (customConfigFile.exists()) { + Log.info("[EmulationFragment] Found existing custom settings for ${game.title}, loading them") + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + } else { + Log.info("[EmulationFragment] No custom settings found for ${game.title}, using global settings") + NativeConfig.reloadGlobalConfig() + } + } else { + // Default case - use global settings + Log.info("[EmulationFragment] Using global settings") + NativeConfig.reloadGlobalConfig() + } + } catch (e: Exception) { + Log.error("[EmulationFragment] Error loading configuration: ${e.message}") + Log.info("[EmulationFragment] Falling back to global settings") + try { + NativeConfig.reloadGlobalConfig() + } catch (fallbackException: Exception) { + Log.error("[EmulationFragment] Critical error: could not load global config: ${fallbackException.message}") + throw fallbackException + } } // Install the selected driver asynchronously as the game starts @@ -209,12 +247,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { CoroutineScope(Dispatchers.Main).launch { try { // Find the game first to get the title for confirmation + Toast.makeText(requireContext(), "Searching for game...", Toast.LENGTH_SHORT).show() val foundGame = CustomSettingsHandler.findGameByTitleId(titleId, requireContext()) if (foundGame == null) { Log.error("[EmulationFragment] Game not found for title ID: $titleId") Toast.makeText( requireContext(), - "Game not found in library for title ID: $titleId", + "Game not found: $titleId", Toast.LENGTH_LONG ).show() requireActivity().finish() @@ -241,28 +280,75 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (intentGame == null) { Log.error("[EmulationFragment] Custom settings processing failed for title ID: $titleId") + // Ask user if they want to launch with default settings Toast.makeText( requireContext(), - "Failed to apply custom settings. This could be due to:\n• User cancelled configuration overwrite\n• Driver installation failed\n• Missing required drivers", - Toast.LENGTH_LONG + "Custom settings failed", + Toast.LENGTH_SHORT ).show() - requireActivity().finish() - return@launch - } - isCustomSettingsIntent = true + val launchWithDefault = askUserToLaunchWithDefaultSettings( + foundGame.title, + "This could be due to:\n• User cancelled configuration overwrite\n• Driver installation failed\n• Missing required drivers" + ) + + if (launchWithDefault) { + Log.info("[EmulationFragment] User chose to launch with default settings") + Toast.makeText( + requireContext(), + "Launching with default settings", + Toast.LENGTH_SHORT + ).show() + intentGame = foundGame + isCustomSettingsIntent = false + } else { + Log.info("[EmulationFragment] User cancelled launch after custom settings failure") + Toast.makeText( + requireContext(), + "Launch cancelled", + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return@launch + } + } else { + Toast.makeText( + requireContext(), + "Custom settings applied", + Toast.LENGTH_SHORT + ).show() + isCustomSettingsIntent = true + } } else { // Handle title-only launch (no custom settings) + Log.info("[EmulationFragment] Launching game with default settings") + Toast.makeText( + requireContext(), + "Launching ${foundGame.title}", + Toast.LENGTH_SHORT + ).show() intentGame = foundGame + isCustomSettingsIntent = false } - finishGameSetup() + // Ensure we have a valid game before finishing setup + if (intentGame != null) { + finishGameSetup() + } else { + Log.error("[EmulationFragment] No valid game found after processing intent") + Toast.makeText( + requireContext(), + "Failed to initialize game", + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + } } catch (e: Exception) { Log.error("[EmulationFragment] Error processing EmuReady intent: ${e.message}") Toast.makeText( requireContext(), - "Error processing EmuReady request: ${e.message}", + "Error: ${e.message?.take(50) ?: "Unknown error"}", Toast.LENGTH_LONG ).show() requireActivity().finish() @@ -272,7 +358,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { Log.error("[EmulationFragment] EmuReady intent missing title_id") Toast.makeText( requireContext(), - "Invalid EmuReady request: missing title ID", + "Invalid request: missing title ID", Toast.LENGTH_SHORT ).show() requireActivity().finish() @@ -306,6 +392,31 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + /** + * Ask user if they want to launch with default settings when custom settings fail + */ + private suspend fun askUserToLaunchWithDefaultSettings(gameTitle: String, errorMessage: String): Boolean { + return suspendCoroutine { continuation -> + requireActivity().runOnUiThread { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Custom Settings Failed") + .setMessage( + "Failed to apply custom settings for \"$gameTitle\":\n\n" + + "$errorMessage\n\n" + + "Would you like to launch the game with default settings instead?" + ) + .setPositiveButton("Launch with Default Settings") { _, _ -> + continuation.resume(true) + } + .setNegativeButton("Cancel") { _, _ -> + continuation.resume(false) + } + .setCancelable(false) + .show() + } + } + } + /** * Initialize the UI and start emulation in here. */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt index cb0b505ff9..2048ff4008 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt @@ -91,9 +91,11 @@ object CustomSettingsHandler { val configFile = getConfigFile(titleId) if (configFile.exists() && activity != null) { Log.info("[CustomSettingsHandler] Config file already exists, asking user for confirmation") + Toast.makeText(activity, "Config exists, asking to overwrite", Toast.LENGTH_SHORT).show() val shouldOverwrite = askUserToOverwriteConfig(activity, game.title) if (!shouldOverwrite) { Log.info("[CustomSettingsHandler] User chose not to overwrite existing config") + Toast.makeText(activity, "Overwrite cancelled", Toast.LENGTH_SHORT).show() return null } } @@ -103,9 +105,11 @@ object CustomSettingsHandler { val driverPath = DriverResolver.extractDriverPath(customSettings) if (driverPath != null) { Log.info("[CustomSettingsHandler] Custom settings specify driver: $driverPath") + Toast.makeText(activity, "Checking driver: ${driverPath.split("/").lastOrNull()?.take(20) ?: "driver"}", Toast.LENGTH_SHORT).show() val driverExists = DriverResolver.ensureDriverExists(driverPath, activity, driverViewModel) if (!driverExists) { Log.error("[CustomSettingsHandler] Required driver not available: $driverPath") + Toast.makeText(activity, "Driver unavailable", Toast.LENGTH_SHORT).show() // Don't write config if driver installation failed return null } @@ -115,6 +119,7 @@ object CustomSettingsHandler { // Only write the config file after all checks pass if (!writeConfigFile(titleId, customSettings)) { Log.error("[CustomSettingsHandler] Failed to write config file") + Toast.makeText(activity, "Config write failed", Toast.LENGTH_SHORT).show() return null } @@ -122,9 +127,11 @@ object CustomSettingsHandler { try { NativeConfig.initializePerGameConfig(game.programId, configFile.nameWithoutExtension) Log.info("[CustomSettingsHandler] Successfully applied custom settings") + Toast.makeText(activity, "Custom settings applied", Toast.LENGTH_SHORT).show() return game } catch (e: Exception) { Log.error("[CustomSettingsHandler] Failed to apply custom settings: ${e.message}") + Toast.makeText(activity, "Config apply failed", Toast.LENGTH_SHORT).show() return null } } @@ -161,10 +168,10 @@ object CustomSettingsHandler { } if (foundGame != null) { Log.info("[CustomSettingsHandler] Found game: ${foundGame.title} at ${foundGame.path}") - Toast.makeText(context, "Found game: ${foundGame.title}", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Found: ${foundGame.title}", Toast.LENGTH_SHORT).show() } else { Log.warning("[CustomSettingsHandler] No game found for title ID: $titleId") - Toast.makeText(context, "Game not found for title ID: $titleId", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Game not found: $titleId", Toast.LENGTH_SHORT).show() } return foundGame } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt index 386a84e7d9..bcd5204240 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt @@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.utils +import android.widget.Toast import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -283,6 +284,7 @@ object DriverResolver { ): Boolean { return try { Log.info("[DriverResolver] Downloading driver: ${artifact.name}") + Toast.makeText(activity, "Downloading driver...", Toast.LENGTH_SHORT).show() val cacheDir = activity.externalCacheDir ?: throw IOException("Cache directory not available") @@ -326,6 +328,7 @@ object DriverResolver { if (GpuDriverHelper.copyDriverToInternalStorage(file.toUri())) { driverViewModel.onDriverAdded(Pair(driverPath, driverData)) Log.info("[DriverResolver] Successfully installed driver: ${driverData.name}") + Toast.makeText(activity, "Driver installed", Toast.LENGTH_SHORT).show() true } else { throw IOException("Failed to install driver")