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