feat(android): add automatic GPU driver download for intent launches (#279)

Reviewed-on: #279
Reviewed-by: crueter <crueter@eden-emu.dev>
Co-authored-by: Producdevity <y.gherbi.dev@gmail.com>
Co-committed-by: Producdevity <y.gherbi.dev@gmail.com>
This commit is contained in:
Producdevity 2025-08-20 19:48:07 +02:00 committed by crueter
parent 7ce051cfb3
commit 0e7203df34
Signed by: crueter
GPG key ID: 425ACD2D4830EBC6
8 changed files with 598 additions and 46 deletions

View file

@ -151,7 +151,7 @@ android {
create("genshinSpoof") {
dimension = "version"
resValue("string", "app_name_suffixed", "Eden Optimised")
applicationId = "com.miHoYo.Yuanshen"
applicationId = "com.miHoYo.Yuanshen"
}
}
}

View file

@ -69,6 +69,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting {
USE_LRU_CACHE("use_lru_cache");
external fun isRaiiEnabled(): Boolean
// external fun isFrameSkippingEnabled(): Boolean
external fun isFrameInterpolationEnabled(): Boolean

View file

@ -58,7 +58,7 @@ enum class IntSetting(override val key: String) : AbstractIntSetting {
OFFLINE_WEB_APPLET("offline_web_applet_mode"),
LOGIN_SHARE_APPLET("login_share_applet_mode"),
WIFI_WEB_AUTH_APPLET("wifi_web_auth_applet_mode"),
MY_PAGE_APPLET("my_page_applet_mode"),
MY_PAGE_APPLET("my_page_applet_mode")
;
override fun getInt(needsGlobal: Boolean): Int = NativeConfig.getInt(key, needsGlobal)

View file

@ -174,7 +174,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
}
game = gameToUse
} catch (e: Exception) {
Log.error("[EmulationFragment] Error during game setup: ${e.message}")
Toast.makeText(
@ -193,10 +192,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
game?.let { gameInstance ->
val customConfigFile = SettingsFile.getCustomSettingsFile(gameInstance)
if (customConfigFile.exists()) {
Log.info("[EmulationFragment] Found existing custom settings for ${gameInstance.title}, loading them")
Log.info(
"[EmulationFragment] Found existing custom settings for ${gameInstance.title}, loading them"
)
SettingsFile.loadCustomConfig(gameInstance)
} else {
Log.info("[EmulationFragment] No custom settings found for ${gameInstance.title}, using global settings")
Log.info(
"[EmulationFragment] No custom settings found for ${gameInstance.title}, using global settings"
)
NativeConfig.reloadGlobalConfig()
}
} ?: run {
@ -225,7 +228,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
try {
NativeConfig.reloadGlobalConfig()
} catch (fallbackException: Exception) {
Log.error("[EmulationFragment] Critical error: could not load global config: ${fallbackException.message}")
Log.error(
"[EmulationFragment] Critical error: could not load global config: ${fallbackException.message}"
)
throw fallbackException
}
}
@ -233,7 +238,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
emulationState = EmulationState(game!!.path) {
return@EmulationState driverViewModel.isInteractionAllowed.value
}
}
/**
@ -333,10 +337,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
val customConfigFile = SettingsFile.getCustomSettingsFile(foundGame)
if (customConfigFile.exists()) {
Log.info("[EmulationFragment] Found existing custom settings for ${foundGame.title}, loading them")
Log.info(
"[EmulationFragment] Found existing custom settings for ${foundGame.title}, loading them"
)
SettingsFile.loadCustomConfig(foundGame)
} else {
Log.info("[EmulationFragment] No custom settings found for ${foundGame.title}, using global settings")
Log.info(
"[EmulationFragment] No custom settings found for ${foundGame.title}, using global settings"
)
}
Toast.makeText(
@ -352,7 +360,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
withContext(Dispatchers.Main) {
try {
finishGameSetup()
Log.info("[EmulationFragment] Game setup complete for intent launch")
Log.info(
"[EmulationFragment] Game setup complete for intent launch"
)
if (_binding != null) {
// Hide loading indicator immediately for intent launches
@ -365,12 +375,16 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
binding.root.post {
if (binding.surfaceEmulation.holder.surface?.isValid == true && !emulationStarted) {
emulationStarted = true
emulationState.newSurface(binding.surfaceEmulation.holder.surface)
emulationState.newSurface(
binding.surfaceEmulation.holder.surface
)
}
}
}
} catch (e: Exception) {
Log.error("[EmulationFragment] Error in finishGameSetup: ${e.message}")
Log.error(
"[EmulationFragment] Error in finishGameSetup: ${e.message}"
)
requireActivity().finish()
return@withContext
}
@ -477,7 +491,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
}
if (game == null) {
Log.warning("[EmulationFragment] Game not yet initialized in onViewCreated - will be set up by async intent handler")
Log.warning(
"[EmulationFragment] Game not yet initialized in onViewCreated - will be set up by async intent handler"
)
return
}

View file

@ -18,6 +18,13 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import android.net.Uri
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.databinding.DialogProgressBinding
import android.view.LayoutInflater
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.CoroutineScope
object CustomSettingsHandler {
const val CUSTOM_CONFIG_ACTION = "dev.eden.eden_emulator.LAUNCH_WITH_CUSTOM_CONFIG"
@ -44,7 +51,9 @@ object CustomSettingsHandler {
// Check if config already exists - this should be handled by the caller
val configFile = getConfigFile(game)
if (configFile.exists()) {
Log.warning("[CustomSettingsHandler] Config file already exists for game: ${game.title}")
Log.warning(
"[CustomSettingsHandler] Config file already exists for game: ${game.title}"
)
}
// Write the config file
@ -121,37 +130,158 @@ object CustomSettingsHandler {
// 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
}
Log.info("[CustomSettingsHandler] Driver not found locally: ${driverFile.name}")
// 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
}
// Ask user if they want to download the missing driver
val shouldDownload = askUserToDownloadDriver(activity, driverFile.name)
if (!shouldDownload) {
Log.info("[CustomSettingsHandler] User declined to download driver")
Toast.makeText(
activity,
activity.getString(R.string.driver_download_cancelled),
Toast.LENGTH_SHORT
).show()
return null
}
Log.info("[CustomSettingsHandler] Driver verified: ${metadata.name}")
// Check network connectivity after user consent
if (!DriverResolver.isNetworkAvailable(activity)) {
Log.error("[CustomSettingsHandler] No network connection available")
Toast.makeText(
activity,
activity.getString(R.string.network_unavailable),
Toast.LENGTH_LONG
).show()
return null
}
Log.info("[CustomSettingsHandler] User approved, downloading driver")
// Show progress dialog for driver download
val dialogBinding = DialogProgressBinding.inflate(LayoutInflater.from(activity))
dialogBinding.progressBar.isIndeterminate = false
dialogBinding.title.text = activity.getString(R.string.installing_driver)
dialogBinding.status.text = activity.getString(R.string.downloading)
val progressDialog = MaterialAlertDialogBuilder(activity)
.setView(dialogBinding.root)
.setCancelable(false)
.create()
withContext(Dispatchers.Main) {
progressDialog.show()
}
try {
// Set up progress channel for thread-safe UI updates
val progressChannel = Channel<Int>(Channel.CONFLATED)
val progressJob = CoroutineScope(Dispatchers.Main).launch {
for (progress in progressChannel) {
dialogBinding.progressBar.progress = progress
}
}
// Attempt to download and install the driver
val driverUri = DriverResolver.ensureDriverAvailable(driverPath, activity) { progress ->
progressChannel.trySend(progress.toInt())
}
progressChannel.close()
progressJob.cancel()
withContext(Dispatchers.Main) {
progressDialog.dismiss()
}
if (driverUri == null) {
Log.error(
"[CustomSettingsHandler] Failed to download driver: ${driverFile.name}"
)
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()
return null
}
// Verify the downloaded driver
val installedFile = File(driverPath)
val metadata = GpuDriverHelper.getMetadataFromZip(installedFile)
if (metadata.name == null) {
Log.error(
"[CustomSettingsHandler] Downloaded driver is invalid: $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
}
// Add to driver list
driverViewModel.onDriverAdded(Pair(driverPath, metadata))
Log.info(
"[CustomSettingsHandler] Successfully downloaded and installed driver: ${metadata.name}"
)
Toast.makeText(
activity,
activity.getString(
R.string.successfully_installed,
metadata.name ?: driverFile.name
),
Toast.LENGTH_SHORT
).show()
} catch (e: Exception) {
withContext(Dispatchers.Main) {
progressDialog.dismiss()
}
Log.error("[CustomSettingsHandler] Error downloading driver: ${e.message}")
Toast.makeText(
activity,
activity.getString(
R.string.custom_settings_failed_message,
game.title,
e.message ?: activity.getString(
R.string.driver_not_found,
driverFile.name
)
),
Toast.LENGTH_LONG
).show()
return null
}
} else {
// Driver exists, verify it's valid
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}")
}
}
}
@ -290,6 +420,29 @@ object CustomSettingsHandler {
}
}
/**
* Ask user if they want to download a missing driver
*/
private suspend fun askUserToDownloadDriver(activity: FragmentActivity, driverName: String): Boolean {
return suspendCoroutine { continuation ->
activity.runOnUiThread {
MaterialAlertDialogBuilder(activity)
.setTitle(activity.getString(R.string.driver_missing_title))
.setMessage(
activity.getString(R.string.driver_missing_message, driverName)
)
.setPositiveButton(activity.getString(R.string.download)) { _, _ ->
continuation.resume(true)
}
.setNegativeButton(activity.getString(R.string.cancel)) { _, _ ->
continuation.resume(false)
}
.setCancelable(false)
.show()
}
}
}
/**
* Extract driver path from custom settings INI content
*/

View file

@ -0,0 +1,371 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.utils
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.yuzu.yuzu_emu.fragments.DriverFetcherFragment
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.ConcurrentHashMap
import okhttp3.ConnectionPool
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import kotlinx.coroutines.delay
import kotlin.math.pow
/**
* Resolves driver download URLs from filenames by searching GitHub repositories
*/
object DriverResolver {
private const val CONNECTION_TIMEOUT_SECONDS = 30L
private const val CACHE_DURATION_MS = 3600000L // 1 hour
private const val BUFFER_SIZE = 8192
private const val MIN_API_CALL_INTERVAL = 2000L // 2 seconds between API calls
private const val MAX_RETRY_COUNT = 3
@Volatile
private var client: OkHttpClient? = null
private fun getClient(): OkHttpClient {
return client ?: synchronized(this) {
client ?: OkHttpClient.Builder()
.connectTimeout(CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build().also { client = it }
}
}
// Driver repository paths - (from DriverFetcherFragment) might extract these to a config file later
private val repositories = listOf(
"MrPurple666/purple-turnip",
"crueter/GameHub-8Elite-Drivers",
"K11MCH1/AdrenoToolsDrivers",
"Weab-chan/freedreno_turnip-CI"
)
private val urlCache = ConcurrentHashMap<String, ResolvedDriver>()
private val releaseCache = ConcurrentHashMap<String, List<DriverFetcherFragment.Release>>()
private var lastCacheTime = 0L
private var lastApiCallTime = 0L
data class ResolvedDriver(
val downloadUrl: String,
val repoPath: String,
val releaseTag: String,
val filename: String
)
/**
* Resolve a driver download URL from its filename
* @param filename The driver filename (e.g., "turnip_mrpurple-T19-toasted.adpkg.zip")
* @return ResolvedDriver with download URL and metadata, or null if not found
*/
suspend fun resolveDriverUrl(filename: String): ResolvedDriver? {
// Validate input
require(filename.isNotBlank()) { "Filename cannot be blank" }
require(!filename.contains("..")) { "Invalid filename: path traversal detected" }
// Check cache first
urlCache[filename]?.let {
Log.info("[DriverResolver] Found cached URL for $filename")
return it
}
Log.info("[DriverResolver] Resolving download URL for: $filename")
// Clear cache if expired
if (System.currentTimeMillis() - lastCacheTime > CACHE_DURATION_MS) {
releaseCache.clear()
lastCacheTime = System.currentTimeMillis()
}
return coroutineScope {
// Search all repositories in parallel
repositories.map { repoPath ->
async {
searchRepository(repoPath, filename)
}
}.mapNotNull { it.await() }.firstOrNull().also { resolved ->
// Cache the result if found
resolved?.let {
urlCache[filename] = it
Log.info("[DriverResolver] Cached resolution for $filename from ${it.repoPath}")
}
}
}
}
/**
* Search a specific repository for a driver file
*/
private suspend fun searchRepository(repoPath: String, filename: String): ResolvedDriver? {
return withContext(Dispatchers.IO) {
try {
// Get releases from cache or fetch
val releases = releaseCache[repoPath] ?: fetchReleases(repoPath).also {
releaseCache[repoPath] = it
}
// Search through all releases and artifacts
for (release in releases) {
for (artifact in release.artifacts) {
if (artifact.name == filename) {
Log.info(
"[DriverResolver] Found $filename in $repoPath/${release.tagName}"
)
return@withContext ResolvedDriver(
downloadUrl = artifact.url.toString(),
repoPath = repoPath,
releaseTag = release.tagName,
filename = filename
)
}
}
}
null
} catch (e: Exception) {
Log.error("[DriverResolver] Failed to search $repoPath: ${e.message}")
null
}
}
}
/**
* Fetch releases from a GitHub repository
*/
private suspend fun fetchReleases(repoPath: String): List<DriverFetcherFragment.Release> = withContext(
Dispatchers.IO
) {
// Rate limiting
val timeSinceLastCall = System.currentTimeMillis() - lastApiCallTime
if (timeSinceLastCall < MIN_API_CALL_INTERVAL) {
delay(MIN_API_CALL_INTERVAL - timeSinceLastCall)
}
lastApiCallTime = System.currentTimeMillis()
// Retry logic with exponential backoff
var retryCount = 0
var lastException: Exception? = null
while (retryCount < MAX_RETRY_COUNT) {
try {
val request = Request.Builder()
.url("https://api.github.com/repos/$repoPath/releases")
.header("Accept", "application/vnd.github.v3+json")
.build()
return@withContext getClient().newCall(request).execute().use { response ->
when {
response.code == 404 -> throw IOException("Repository not found: $repoPath")
response.code == 403 -> {
val resetTime = response.header("X-RateLimit-Reset")?.toLongOrNull() ?: 0
throw IOException(
"API rate limit exceeded. Resets at ${java.util.Date(
resetTime * 1000
)}"
)
}
!response.isSuccessful -> throw IOException(
"HTTP ${response.code}: ${response.message}"
)
}
val body = response.body?.string()
?: throw IOException("Empty response from $repoPath")
// Determine if this repo uses tag names (from DriverFetcherFragment logic)
val useTagName = repoPath.contains("K11MCH1")
val sortMode = if (useTagName) {
DriverFetcherFragment.SortMode.PublishTime
} else {
DriverFetcherFragment.SortMode.Default
}
DriverFetcherFragment.Release.fromJsonArray(body, useTagName, sortMode)
}
} catch (e: IOException) {
lastException = e
if (retryCount == MAX_RETRY_COUNT - 1) throw e
delay((2.0.pow(retryCount) * 1000).toLong())
retryCount++
}
}
throw lastException ?: IOException(
"Failed to fetch releases after $MAX_RETRY_COUNT attempts"
)
}
/**
* Download a driver file to the cache directory
* @param resolvedDriver The resolved driver information
* @param context Android context for cache directory
* @return The downloaded file, or null if download failed
*/
suspend fun downloadDriver(
resolvedDriver: ResolvedDriver,
context: Context,
onProgress: ((Float) -> Unit)? = null
): File? {
return withContext(Dispatchers.IO) {
try {
Log.info(
"[DriverResolver] Downloading ${resolvedDriver.filename} from ${resolvedDriver.repoPath}"
)
val cacheDir = context.externalCacheDir ?: throw IOException("Failed to access cache directory")
cacheDir.mkdirs()
val file = File(cacheDir, resolvedDriver.filename)
// If file already exists in cache and has content, return it
if (file.exists() && file.length() > 0) {
Log.info("[DriverResolver] Using cached file: ${file.absolutePath}")
return@withContext file
}
val request = Request.Builder()
.url(resolvedDriver.downloadUrl)
.header("Accept", "application/octet-stream")
.build()
getClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
response.body?.use { body ->
val contentLength = body.contentLength()
body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var totalBytesRead = 0L
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
val progress = (totalBytesRead.toFloat() / contentLength) * 100f
onProgress?.invoke(progress)
}
}
}
}
} ?: throw IOException("Empty response body")
}
if (file.length() == 0L) {
file.delete()
throw IOException("Downloaded file is empty")
}
Log.info(
"[DriverResolver] Successfully downloaded ${file.length()} bytes to ${file.absolutePath}"
)
file
} catch (e: Exception) {
Log.error("[DriverResolver] Download failed: ${e.message}")
null
}
}
}
/**
* Download and install a driver if not already present
* @param driverPath The driver filename or full path
* @param context Android context
* @param onProgress Optional progress callback (0-100)
* @return Uri of the installed driver, or null if failed
*/
suspend fun ensureDriverAvailable(
driverPath: String,
context: Context,
onProgress: ((Float) -> Unit)? = null
): Uri? {
// Extract filename from path
val filename = driverPath.substringAfterLast('/')
// Check if driver already exists locally
val localPath = "${GpuDriverHelper.driverStoragePath}$filename"
val localFile = File(localPath)
if (localFile.exists() && localFile.length() > 0) {
Log.info("[DriverResolver] Driver already exists locally: $localPath")
return Uri.fromFile(localFile)
}
Log.info("[DriverResolver] Driver not found locally, attempting to download: $filename")
// Resolve download URL
val resolvedDriver = resolveDriverUrl(filename)
if (resolvedDriver == null) {
Log.error("[DriverResolver] Failed to resolve download URL for $filename")
return null
}
// Download the driver with progress callback
val downloadedFile = downloadDriver(resolvedDriver, context, onProgress)
if (downloadedFile == null) {
Log.error("[DriverResolver] Failed to download driver $filename")
return null
}
// Install the driver to internal storage
val downloadedUri = Uri.fromFile(downloadedFile)
if (GpuDriverHelper.copyDriverToInternalStorage(downloadedUri)) {
Log.info("[DriverResolver] Successfully installed driver to internal storage")
// Clean up cache file
downloadedFile.delete()
return Uri.fromFile(File(localPath))
} else {
Log.error("[DriverResolver] Failed to copy driver to internal storage")
downloadedFile.delete()
return null
}
}
/**
* Check network connectivity
*/
fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
/**
* Clear all caches
*/
fun clearCache() {
urlCache.clear()
releaseCache.clear()
lastCacheTime = 0L
lastApiCallTime = 0L
}
/**
* Clean up resources
*/
fun cleanup() {
client?.dispatcher?.executorService?.shutdown()
client?.connectionPool?.evictAll()
client = null
clearCache()
}
}

View file

@ -52,8 +52,10 @@ class GradientBorderCardView @JvmOverloads constructor(
if (isEdenTheme) {
// Gradient for Eden theme
borderPaint.shader = LinearGradient(
0f, 0f,
w.toFloat(), h.toFloat(),
0f,
0f,
w.toFloat(),
h.toFloat(),
context.getColor(R.color.eden_border_gradient_start),
context.getColor(R.color.eden_border_gradient_end),
Shader.TileMode.CLAMP
@ -62,7 +64,11 @@ class GradientBorderCardView @JvmOverloads constructor(
// Solid color for other themes
borderPaint.shader = null
val typedValue = android.util.TypedValue()
context.theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true)
context.theme.resolveAttribute(
com.google.android.material.R.attr.colorPrimary,
typedValue,
true
)
borderPaint.color = typedValue.data
}

View file

@ -816,6 +816,11 @@
<string name="driver_not_available_message">The selected driver is not available for download.</string>
<string name="driver_not_found">Required driver not installed: %s</string>
<string name="invalid_driver_file">Invalid driver file: %s</string>
<string name="network_unavailable">No network connection available. Please check your internet connection and try again.</string>
<string name="driver_missing_title">GPU Driver Required</string>
<string name="driver_missing_message">The game configuration requires GPU driver \"%s\" which is not installed on your device.\n\nWould you like to download and install it now?</string>
<string name="driver_download_cancelled">Driver download cancelled. The game cannot be launched without the required driver.</string>
<string name="download">Download</string>
<!-- Emulation Menu -->
<string name="emulation_exit">Exit emulation</string>