diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 4402317529..5763f3120f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -34,6 +34,7 @@ import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.features.fetcher.SpacingItemDecoration import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.HomeSetting @@ -251,6 +252,8 @@ class HomeSettingsFragment : Fragment() { viewLifecycleOwner, optionsList ) + val spacing = resources.getDimensionPixelSize(R.dimen.spacing_small) + addItemDecoration(SpacingItemDecoration(spacing)) } setInsets() 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 2048ff4008..508f9de463 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 @@ -10,11 +10,14 @@ 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.R 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 +import android.net.Uri +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile object CustomSettingsHandler { const val CUSTOM_CONFIG_ACTION = "dev.eden.eden_emulator.LAUNCH_WITH_CUSTOM_CONFIG" @@ -39,21 +42,21 @@ object CustomSettingsHandler { } // Check if config already exists - this should be handled by the caller - val configFile = getConfigFile(titleId) + val configFile = getConfigFile(game) if (configFile.exists()) { - Log.warning("[CustomSettingsHandler] Config file already exists for title ID: $titleId") - // The caller should have already asked the user about overwriting + Log.warning("[CustomSettingsHandler] Config file already exists for game: ${game.title}") } // Write the config file - if (!writeConfigFile(titleId, customSettings)) { + if (!writeConfigFile(game, customSettings)) { Log.error("[CustomSettingsHandler] Failed to write config file") return null } // Initialize per-game config try { - NativeConfig.initializePerGameConfig(game.programId, configFile.nameWithoutExtension) + val fileName = FileUtil.getFilename(Uri.parse(game.path)) + NativeConfig.initializePerGameConfig(game.programId, fileName) Log.info("[CustomSettingsHandler] Successfully applied custom settings") return game } catch (e: Exception) { @@ -88,50 +91,104 @@ object CustomSettingsHandler { } // Check if config already exists - val configFile = getConfigFile(titleId) + val configFile = getConfigFile(game) 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() + Log.info( + "[CustomSettingsHandler] Config file already exists, asking user for confirmation" + ) + Toast.makeText( + activity, + activity.getString(R.string.config_exists_prompt), + 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() + Toast.makeText( + activity, + activity.getString(R.string.overwrite_cancelled), + Toast.LENGTH_SHORT + ).show() return null } } // Check for driver requirements if activity and driverViewModel are provided if (activity != null && driverViewModel != null) { - val driverPath = DriverResolver.extractDriverPath(customSettings) + val driverPath = 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 + // Check if driver exists in the driver storage + val driverFile = File(driverPath) + if (!driverFile.exists()) { + Log.error("[CustomSettingsHandler] Required driver not found: $driverPath") + Toast.makeText( + activity, + activity.getString( + R.string.custom_settings_failed_message, + game.title, + activity.getString(R.string.driver_not_found, driverFile.name) + ), + Toast.LENGTH_LONG + ).show() + // Don't write config if driver is missing return null } + + // Verify it's a valid driver + val metadata = GpuDriverHelper.getMetadataFromZip(driverFile) + if (metadata.name == null) { + Log.error("[CustomSettingsHandler] Invalid driver file: $driverPath") + Toast.makeText( + activity, + activity.getString( + R.string.custom_settings_failed_message, + game.title, + activity.getString(R.string.invalid_driver_file, driverFile.name) + ), + Toast.LENGTH_LONG + ).show() + return null + } + + Log.info("[CustomSettingsHandler] Driver verified: ${metadata.name}") } } // Only write the config file after all checks pass - if (!writeConfigFile(titleId, customSettings)) { + if (!writeConfigFile(game, customSettings)) { Log.error("[CustomSettingsHandler] Failed to write config file") - Toast.makeText(activity, "Config write failed", Toast.LENGTH_SHORT).show() + activity?.let { + Toast.makeText( + it, + it.getString(R.string.config_write_failed), + Toast.LENGTH_SHORT + ).show() + } return null } // Initialize per-game config try { - NativeConfig.initializePerGameConfig(game.programId, configFile.nameWithoutExtension) + SettingsFile.loadCustomConfig(game) Log.info("[CustomSettingsHandler] Successfully applied custom settings") - Toast.makeText(activity, "Custom settings applied", Toast.LENGTH_SHORT).show() + activity?.let { + Toast.makeText( + it, + it.getString(R.string.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() + activity?.let { + Toast.makeText( + it, + it.getString(R.string.config_apply_failed), + Toast.LENGTH_SHORT + ).show() + } return null } } @@ -154,7 +211,7 @@ object CustomSettingsHandler { // First check cached games for fast lookup GameHelper.cachedGameList.find { game -> game.programId == programIdDecimal || - game.programIdHex.equals(expectedHex, ignoreCase = true) + game.programIdHex.equals(expectedHex, ignoreCase = true) }?.let { foundGame -> Log.info("[CustomSettingsHandler] Found game in cache: ${foundGame.title}") return foundGame @@ -164,22 +221,27 @@ object CustomSettingsHandler { val allGames = GameHelper.getGames() val foundGame = allGames.find { game -> game.programId == programIdDecimal || - game.programIdHex.equals(expectedHex, ignoreCase = true) + game.programIdHex.equals(expectedHex, ignoreCase = true) } if (foundGame != null) { Log.info("[CustomSettingsHandler] Found game: ${foundGame.title} at ${foundGame.path}") - 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: $titleId", Toast.LENGTH_SHORT).show() } return foundGame } /** - * Get the config file path for a title ID + * Get the config file path for a game */ - private fun getConfigFile(titleId: String): File { + private fun getConfigFile(game: Game): File { + return SettingsFile.getCustomSettingsFile(game) + } + + /** + * Get the title ID config file path + */ + private fun getTitleIdConfigFile(titleId: String): File { val configDir = File(DirectoryInitialization.userDirectory, "config/custom") return File(configDir, "$titleId.ini") } @@ -187,14 +249,14 @@ object CustomSettingsHandler { /** * Write the config file with the custom settings */ - private fun writeConfigFile(titleId: String, customSettings: String): Boolean { + private fun writeConfigFile(game: Game, customSettings: String): Boolean { return try { - val configDir = File(DirectoryInitialization.userDirectory, "config/custom") - if (!configDir.exists()) { + val configFile = getConfigFile(game) + val configDir = configFile.parentFile + if (configDir != null && !configDir.exists()) { configDir.mkdirs() } - val configFile = File(configDir, "$titleId.ini") configFile.writeText(customSettings) Log.info("[CustomSettingsHandler] Wrote config file: ${configFile.absolutePath}") @@ -212,16 +274,14 @@ object CustomSettingsHandler { return suspendCoroutine { continuation -> activity.runOnUiThread { MaterialAlertDialogBuilder(activity) - .setTitle("Configuration Already Exists") + .setTitle(activity.getString(R.string.config_already_exists_title)) .setMessage( - "Custom settings already exist for '$gameTitle'.\n\n" + - "Do you want to overwrite the existing configuration?\n\n" + - "This action cannot be undone." + activity.getString(R.string.config_already_exists_message, gameTitle) ) - .setPositiveButton("Overwrite") { _, _ -> + .setPositiveButton(activity.getString(R.string.overwrite)) { _, _ -> continuation.resume(true) } - .setNegativeButton("Cancel") { _, _ -> + .setNegativeButton(activity.getString(R.string.cancel)) { _, _ -> continuation.resume(false) } .setCancelable(false) @@ -229,4 +289,26 @@ object CustomSettingsHandler { } } } + + /** + * Extract driver path from custom settings INI content + */ + private 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 + } } 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 deleted file mode 100644 index 21585c9218..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt +++ /dev/null @@ -1,372 +0,0 @@ -// 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.widget.Toast -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.R -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(activity.getString(R.string.missing_gpu_driver_title)) - .setMessage(activity.getString(R.string.missing_gpu_driver_message, driverName)) - .setPositiveButton(activity.getString(R.string.install)) { _, _ -> - continuation.resume(true) - } - .setNegativeButton(activity.getString(R.string.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}") - Toast.makeText( - activity, - activity.getString(R.string.downloading_driver), - Toast.LENGTH_SHORT - ).show() - - 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}") - Toast.makeText( - activity, - activity.getString(R.string.driver_installed), - Toast.LENGTH_SHORT - ).show() - 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(activity.getString(R.string.driver_installation_failed_title)) - .setMessage( - activity.getString(R.string.driver_installation_failed_message, e.message) - ) - .setPositiveButton(activity.getString(R.string.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(activity.getString(R.string.driver_not_available_title)) - .setMessage(activity.getString(R.string.driver_not_available_message, driverName)) - .setPositiveButton(activity.getString(R.string.ok)) { dialog, _ -> dialog.dismiss() } - .show() - } - } -}