forked from eden-emu/eden
feat(android): add automatic GPU driver download for intent launches (#279)
Reviewed-on: eden-emu/eden#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:
parent
7ce051cfb3
commit
0e7203df34
8 changed files with 598 additions and 46 deletions
|
@ -151,7 +151,7 @@ android {
|
|||
create("genshinSpoof") {
|
||||
dimension = "version"
|
||||
resValue("string", "app_name_suffixed", "Eden Optimised")
|
||||
applicationId = "com.miHoYo.Yuanshen"
|
||||
applicationId = "com.miHoYo.Yuanshen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue