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:
parent
7ce051cfb3
commit
0e7203df34
8 changed files with 598 additions and 46 deletions
|
@ -69,6 +69,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting {
|
||||||
USE_LRU_CACHE("use_lru_cache");
|
USE_LRU_CACHE("use_lru_cache");
|
||||||
|
|
||||||
external fun isRaiiEnabled(): Boolean
|
external fun isRaiiEnabled(): Boolean
|
||||||
|
|
||||||
// external fun isFrameSkippingEnabled(): Boolean
|
// external fun isFrameSkippingEnabled(): Boolean
|
||||||
external fun isFrameInterpolationEnabled(): Boolean
|
external fun isFrameInterpolationEnabled(): Boolean
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ enum class IntSetting(override val key: String) : AbstractIntSetting {
|
||||||
OFFLINE_WEB_APPLET("offline_web_applet_mode"),
|
OFFLINE_WEB_APPLET("offline_web_applet_mode"),
|
||||||
LOGIN_SHARE_APPLET("login_share_applet_mode"),
|
LOGIN_SHARE_APPLET("login_share_applet_mode"),
|
||||||
WIFI_WEB_AUTH_APPLET("wifi_web_auth_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)
|
override fun getInt(needsGlobal: Boolean): Int = NativeConfig.getInt(key, needsGlobal)
|
||||||
|
|
|
@ -174,7 +174,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
}
|
}
|
||||||
|
|
||||||
game = gameToUse
|
game = gameToUse
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.error("[EmulationFragment] Error during game setup: ${e.message}")
|
Log.error("[EmulationFragment] Error during game setup: ${e.message}")
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
|
@ -193,10 +192,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
game?.let { gameInstance ->
|
game?.let { gameInstance ->
|
||||||
val customConfigFile = SettingsFile.getCustomSettingsFile(gameInstance)
|
val customConfigFile = SettingsFile.getCustomSettingsFile(gameInstance)
|
||||||
if (customConfigFile.exists()) {
|
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)
|
SettingsFile.loadCustomConfig(gameInstance)
|
||||||
} else {
|
} 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()
|
NativeConfig.reloadGlobalConfig()
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: run {
|
||||||
|
@ -225,7 +228,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
try {
|
try {
|
||||||
NativeConfig.reloadGlobalConfig()
|
NativeConfig.reloadGlobalConfig()
|
||||||
} catch (fallbackException: Exception) {
|
} 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
|
throw fallbackException
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,7 +238,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
emulationState = EmulationState(game!!.path) {
|
emulationState = EmulationState(game!!.path) {
|
||||||
return@EmulationState driverViewModel.isInteractionAllowed.value
|
return@EmulationState driverViewModel.isInteractionAllowed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -333,10 +337,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
|
|
||||||
val customConfigFile = SettingsFile.getCustomSettingsFile(foundGame)
|
val customConfigFile = SettingsFile.getCustomSettingsFile(foundGame)
|
||||||
if (customConfigFile.exists()) {
|
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)
|
SettingsFile.loadCustomConfig(foundGame)
|
||||||
} else {
|
} 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(
|
Toast.makeText(
|
||||||
|
@ -352,7 +360,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
finishGameSetup()
|
finishGameSetup()
|
||||||
Log.info("[EmulationFragment] Game setup complete for intent launch")
|
Log.info(
|
||||||
|
"[EmulationFragment] Game setup complete for intent launch"
|
||||||
|
)
|
||||||
|
|
||||||
if (_binding != null) {
|
if (_binding != null) {
|
||||||
// Hide loading indicator immediately for intent launches
|
// Hide loading indicator immediately for intent launches
|
||||||
|
@ -365,12 +375,16 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
binding.root.post {
|
binding.root.post {
|
||||||
if (binding.surfaceEmulation.holder.surface?.isValid == true && !emulationStarted) {
|
if (binding.surfaceEmulation.holder.surface?.isValid == true && !emulationStarted) {
|
||||||
emulationStarted = true
|
emulationStarted = true
|
||||||
emulationState.newSurface(binding.surfaceEmulation.holder.surface)
|
emulationState.newSurface(
|
||||||
|
binding.surfaceEmulation.holder.surface
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.error("[EmulationFragment] Error in finishGameSetup: ${e.message}")
|
Log.error(
|
||||||
|
"[EmulationFragment] Error in finishGameSetup: ${e.message}"
|
||||||
|
)
|
||||||
requireActivity().finish()
|
requireActivity().finish()
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
@ -477,7 +491,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game == null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,13 @@ import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
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 {
|
object CustomSettingsHandler {
|
||||||
const val CUSTOM_CONFIG_ACTION = "dev.eden.eden_emulator.LAUNCH_WITH_CUSTOM_CONFIG"
|
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
|
// Check if config already exists - this should be handled by the caller
|
||||||
val configFile = getConfigFile(game)
|
val configFile = getConfigFile(game)
|
||||||
if (configFile.exists()) {
|
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
|
// Write the config file
|
||||||
|
@ -121,7 +130,73 @@ object CustomSettingsHandler {
|
||||||
// Check if driver exists in the driver storage
|
// Check if driver exists in the driver storage
|
||||||
val driverFile = File(driverPath)
|
val driverFile = File(driverPath)
|
||||||
if (!driverFile.exists()) {
|
if (!driverFile.exists()) {
|
||||||
Log.error("[CustomSettingsHandler] Required driver not found: $driverPath")
|
Log.info("[CustomSettingsHandler] Driver not found locally: ${driverFile.name}")
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driverUri == null) {
|
||||||
|
Log.error(
|
||||||
|
"[CustomSettingsHandler] Failed to download driver: ${driverFile.name}"
|
||||||
|
)
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
activity,
|
activity,
|
||||||
activity.getString(
|
activity.getString(
|
||||||
|
@ -131,11 +206,66 @@ object CustomSettingsHandler {
|
||||||
),
|
),
|
||||||
Toast.LENGTH_LONG
|
Toast.LENGTH_LONG
|
||||||
).show()
|
).show()
|
||||||
// Don't write config if driver is missing
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's a valid driver
|
// 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)
|
val metadata = GpuDriverHelper.getMetadataFromZip(driverFile)
|
||||||
if (metadata.name == null) {
|
if (metadata.name == null) {
|
||||||
Log.error("[CustomSettingsHandler] Invalid driver file: $driverPath")
|
Log.error("[CustomSettingsHandler] Invalid driver file: $driverPath")
|
||||||
|
@ -150,10 +280,10 @@ object CustomSettingsHandler {
|
||||||
).show()
|
).show()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.info("[CustomSettingsHandler] Driver verified: ${metadata.name}")
|
Log.info("[CustomSettingsHandler] Driver verified: ${metadata.name}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only write the config file after all checks pass
|
// Only write the config file after all checks pass
|
||||||
if (!writeConfigFile(game, customSettings)) {
|
if (!writeConfigFile(game, customSettings)) {
|
||||||
|
@ -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
|
* 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) {
|
if (isEdenTheme) {
|
||||||
// Gradient for Eden theme
|
// Gradient for Eden theme
|
||||||
borderPaint.shader = LinearGradient(
|
borderPaint.shader = LinearGradient(
|
||||||
0f, 0f,
|
0f,
|
||||||
w.toFloat(), h.toFloat(),
|
0f,
|
||||||
|
w.toFloat(),
|
||||||
|
h.toFloat(),
|
||||||
context.getColor(R.color.eden_border_gradient_start),
|
context.getColor(R.color.eden_border_gradient_start),
|
||||||
context.getColor(R.color.eden_border_gradient_end),
|
context.getColor(R.color.eden_border_gradient_end),
|
||||||
Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
|
@ -62,7 +64,11 @@ class GradientBorderCardView @JvmOverloads constructor(
|
||||||
// Solid color for other themes
|
// Solid color for other themes
|
||||||
borderPaint.shader = null
|
borderPaint.shader = null
|
||||||
val typedValue = android.util.TypedValue()
|
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
|
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_available_message">The selected driver is not available for download.</string>
|
||||||
<string name="driver_not_found">Required driver not installed: %s</string>
|
<string name="driver_not_found">Required driver not installed: %s</string>
|
||||||
<string name="invalid_driver_file">Invalid driver file: %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 -->
|
<!-- Emulation Menu -->
|
||||||
<string name="emulation_exit">Exit emulation</string>
|
<string name="emulation_exit">Exit emulation</string>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue