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 119517d99b..8693798a08 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 @@ -68,6 +68,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 + } - 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(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 */ 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() + private val releaseCache = ConcurrentHashMap>() + 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 = 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 f1991fee1b..6c2e1046de 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -814,6 +814,11 @@ The selected driver is not available for download. Required driver not installed: %s Invalid driver file: %s + No network connection available. Please check your internet connection and try again. + GPU Driver Required + The game configuration requires GPU driver \"%s\" which is not installed on your device.\n\nWould you like to download and install it now? + Driver download cancelled. The game cannot be launched without the required driver. + Download Exit emulation