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()
+ }
+ }
+}