Browse Source
feat(android): add automatic GPU driver download for intent launches (#279)
feat(android): add automatic GPU driver download for intent launches (#279)
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/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>pull/287/head
committed by
crueter
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
8 changed files with 598 additions and 46 deletions
-
2src/android/app/build.gradle.kts
-
1src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
-
2src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
-
38src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
-
213src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt
-
371src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt
-
12src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt
-
5src/android/app/src/main/res/values/strings.xml
@ -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() |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue