aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorProducdevity <y.gherbi.dev@gmail.com>2025-08-20 19:48:07 +0200
committercrueter <crueter@eden-emu.dev>2025-08-20 19:48:07 +0200
commit0e7203df3478962b69fc8dcc481fa443e096b124 (patch)
tree7246c9133e5d3ce19fde9ad000a9f00bc38c5d6d /src/android
parent7ce051cfb3d21443cec0eedd6f709ee18e030552 (diff)
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>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/build.gradle.kts2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt38
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CustomSettingsHandler.kt213
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt371
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt12
-rw-r--r--src/android/app/src/main/res/values/strings.xml5
8 files changed, 598 insertions, 46 deletions
diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts
index ea0ff5427d..0cbb29b01b 100644
--- a/src/android/app/build.gradle.kts
+++ b/src/android/app/build.gradle.kts
@@ -151,7 +151,7 @@ android {
create("genshinSpoof") {
dimension = "version"
resValue("string", "app_name_suffixed", "Eden Optimised")
- applicationId = "com.miHoYo.Yuanshen"
+ applicationId = "com.miHoYo.Yuanshen"
}
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
index 153d464f68..21a159676d 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
@@ -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
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
index d53aa46265..6ba101263a 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
@@ -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)
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 e80237b5a9..96015e58ec 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
@@ -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
}
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
index 508f9de463..a317be14d5 100644
--- 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
@@ -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
+ }
+
+ // 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()
+ }
- Log.info("[CustomSettingsHandler] Driver verified: ${metadata.name}")
+ 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}")
+ }
}
}
@@ -291,6 +421,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
*/
private fun extractDriverPath(customSettings: String): String? {
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..74f98ccbd2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DriverResolver.kt
@@ -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()
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt
index 7badadd119..8f730fc490 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt
@@ -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
}
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 88474faf3c..06bcdd2d14 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -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>