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
-
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
-
163src/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