diff --git a/app/src/main/java/app/gamenative/api/CommunityConfigService.kt b/app/src/main/java/app/gamenative/api/CommunityConfigService.kt new file mode 100644 index 0000000000..bfe5ce9c6b --- /dev/null +++ b/app/src/main/java/app/gamenative/api/CommunityConfigService.kt @@ -0,0 +1,980 @@ +package app.gamenative.api + +import app.gamenative.BuildConfig +import app.gamenative.utils.Net +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import java.io.IOException +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.ArrayDeque +import java.util.LinkedHashMap +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject + +private const val MAX_CONCURRENT_DEVICE_REQUESTS = 4 +private const val MAX_DEVICE_SLICE_PAGES = 25 +private const val MAX_API_PAGE_SIZE = 200 +private const val MAX_CONFIG_VALUE_CHARS = 64 * 1024 +private const val MAX_LAUNCH_ARGUMENT_CHARS = 4 * 1024 +private const val MAX_ENVIRONMENT_VARIABLES = 64 +private const val MAX_ENVIRONMENT_NAME_CHARS = 128 +private const val MAX_ENVIRONMENT_VALUE_CHARS = 4 * 1024 +private const val MAX_METADATA_CHARS = 256 +private const val MAX_NOTES_CHARS = 4 * 1024 +private const val MAX_TAGS = 20 +private const val MAX_TAG_CHARS = 64 +private const val CACHE_TTL_MILLIS = 2 * 60 * 1000L +private const val MAX_REQUESTS_PER_WINDOW = 4 +private const val REQUEST_WINDOW_MILLIS = 10_000L +private const val DEFAULT_RATE_LIMIT_COOLDOWN_MILLIS = REQUEST_WINDOW_MILLIS +private const val MAX_RATE_LIMIT_COOLDOWN_MILLIS = 60_000L + +enum class CommunityConfigSort(val apiValue: String) { + HIGHEST_RATED("rating"), + NEWEST("created_at"), +} + +internal enum class CommunityGpuCompatibility { + ADRENO_STANDARD, + ADRENO_ELITE, + OTHER, + UNKNOWN, +} + +data class CommunityGame( + val id: Int, + val name: String, +) + +data class CommunityConfigDevice( + val id: Int, + val model: String, + val gpu: String, + val androidVersion: String, + val soc: String, +) + +data class CommunityConfigRun( + val id: Long, + val rating: Int, + val averageFps: Double?, + val tags: List, + val notes: String, + val config: JsonObject, + val createdAt: String, + val appVersion: String, + val sessionLengthSeconds: Long?, + val gameStore: String, + val device: CommunityConfigDevice, +) { + fun configString(key: String): String { + val element = config[key] ?: return "" + return (element as? JsonPrimitive)?.contentOrNull ?: element.toString() + } +} + +internal fun CommunityConfigRun.communityIdentityKey(): String = "$id:${device.id}:$createdAt" + +data class CommunityConfigPage( + val runs: List, + val total: Int, + val page: Int, + val hasMore: Boolean, +) + +class CommunityConfigApiException( + message: String, + val statusCode: Int? = null, + cause: Throwable? = null, +) : IOException(message, cause) + +private class ExpiringCache( + private val maxEntries: Int, + private val ttlMillis: Long, +) { + private data class Entry(val value: V, val expiresAtNanos: Long) + + private val entries = object : LinkedHashMap>(maxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean { + return size > maxEntries + } + } + + @Synchronized + fun get(key: K): V? { + val entry = entries[key] ?: return null + if (System.nanoTime() >= entry.expiresAtNanos) { + entries.remove(key) + return null + } + return entry.value + } + + @Synchronized + fun put(key: K, value: V) { + entries[key] = Entry(value, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(ttlMillis)) + } + + @Synchronized + fun clear() = entries.clear() +} + +internal class CommunityRequestThrottle( + private val maxRequests: Int = MAX_REQUESTS_PER_WINDOW, + windowMillis: Long = REQUEST_WINDOW_MILLIS, + private val nanoTime: () -> Long = System::nanoTime, + private val sleepNanos: (Long) -> Unit = { TimeUnit.NANOSECONDS.sleep(it) }, +) { + private val windowNanos = TimeUnit.MILLISECONDS.toNanos(windowMillis) + private val requestStarts = ArrayDeque(maxRequests) + private var blockedUntilNanos = 0L + + init { + require(maxRequests > 0) + require(windowMillis > 0) + } + + fun awaitPermit() { + while (true) { + val waitNanos = synchronized(this) { + val now = nanoTime() + val expiredAt = now - windowNanos + while (requestStarts.isNotEmpty() && requestStarts.first() <= expiredAt) { + requestStarts.removeFirst() + } + + val quotaWaitNanos = if (requestStarts.size >= maxRequests) { + requestStarts.first() + windowNanos - now + } else { + 0L + } + val cooldownWaitNanos = blockedUntilNanos - now + maxOf(quotaWaitNanos, cooldownWaitNanos).also { + if (it <= 0L) requestStarts.addLast(now) + } + } + if (waitNanos <= 0L) { + return + } + + try { + sleepNanos(waitNanos) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw CommunityConfigApiException("Compatibility request was interrupted", cause = error) + } + } + } + + @Synchronized + fun postpone(delayMillis: Long) { + blockedUntilNanos = maxOf( + blockedUntilNanos, + nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMillis.coerceAtLeast(0L)), + ) + } +} + +class CommunityConfigService internal constructor( + private val client: OkHttpClient = Net.http, + private val baseUrl: String = DEFAULT_BASE_URL, + private val requestThrottle: CommunityRequestThrottle = CommunityRequestThrottle(), +) { + companion object { + private const val DEFAULT_BASE_URL = "https://api.gamenative.app" + private const val MAX_RESPONSE_BYTES = 4L * 1024 * 1024 + + val shared: CommunityConfigService by lazy { CommunityConfigService() } + } + + private data class ConfigCacheKey( + val gameId: Int, + val gpu: String, + val sort: CommunityConfigSort, + val page: Int, + val limit: Int, + val deviceIds: List, + val compatibility: CommunityGpuCompatibility?, + ) + + private val gameSearchCache = ExpiringCache>(12, CACHE_TTL_MILLIS) + private val deviceSearchCache = ExpiringCache>(12, CACHE_TTL_MILLIS) + private val configPageCache = ExpiringCache(24, CACHE_TTL_MILLIS) + + fun clearConfigCache() { + configPageCache.clear() + } + + suspend fun searchGames(query: String): List = withContext(Dispatchers.IO) { + if (query.isBlank()) return@withContext emptyList() + val normalizedQuery = query.trim() + gameSearchCache.get(normalizedQuery.lowercase(Locale.ENGLISH))?.let { return@withContext it } + val url = endpoint("api/games/search") + .addQueryParameter("q", normalizedQuery) + .build() + parseGames(execute(url.toString())).also { + gameSearchCache.put(normalizedQuery.lowercase(Locale.ENGLISH), it) + } + } + + suspend fun findGame(query: String): CommunityGame? { + return selectCommunityGame(query, searchGames(query)) + } + + suspend fun findDevices( + manufacturer: String, + model: String, + gpu: String, + androidVersion: String, + ): List { + val query = communityDeviceQuery(manufacturer, model) + fun select(devices: List) = selectCommunityDevices( + devices = devices, + manufacturer = manufacturer, + model = model, + currentGpu = gpu, + androidVersion = androidVersion, + ) + val primaryMatches = select(searchDevices(query)) + if (primaryMatches.isNotEmpty() || query.equals(model.trim(), ignoreCase = true)) { + return primaryMatches + } + return select(searchDevices(model)) + } + + suspend fun searchDevices(model: String): List = withContext(Dispatchers.IO) { + if (model.isBlank()) return@withContext emptyList() + val normalizedModel = model.trim() + deviceSearchCache.get(normalizedModel.lowercase(Locale.ENGLISH))?.let { return@withContext it } + val url = endpoint("api/devices") + .addQueryParameter("model", normalizedModel) + .build() + parseDevices(execute(url.toString())).also { + deviceSearchCache.put(normalizedModel.lowercase(Locale.ENGLISH), it) + } + } + + suspend fun fetchConfigs( + gameId: Int, + gpu: String?, + sort: CommunityConfigSort, + page: Int, + limit: Int = 20, + deviceIds: List = emptyList(), + ): CommunityConfigPage = withContext(Dispatchers.IO) { + val validDeviceIds = deviceIds.filter { it > 0 }.distinct().sorted() + val normalizedPage = page.coerceAtLeast(0) + val normalizedLimit = limit.coerceIn(1, 50) + val cacheKey = ConfigCacheKey( + gameId = gameId, + gpu = gpu.orEmpty().trim().lowercase(Locale.ENGLISH), + sort = sort, + page = normalizedPage, + limit = normalizedLimit, + deviceIds = validDeviceIds, + compatibility = null, + ) + configPageCache.get(cacheKey)?.let { return@withContext it } + + val result = if (validDeviceIds.size <= 1) { + fetchConfigPage( + gameId = gameId, + gpu = gpu, + sort = sort, + page = normalizedPage, + limit = normalizedLimit, + deviceId = validDeviceIds.singleOrNull(), + ) + } else { + val endExclusive = (normalizedPage + 1L) * normalizedLimit + if (endExclusive > Int.MAX_VALUE) { + throw CommunityConfigApiException("Compatibility page is too large") + } + val targetCount = endExclusive.toInt() + val requestLimiter = Semaphore(minOf(validDeviceIds.size, MAX_CONCURRENT_DEVICE_REQUESTS)) + val slices = coroutineScope { + validDeviceIds.map { deviceId -> + async { + requestLimiter.withPermit { + fetchDeviceConfigSlice( + gameId = gameId, + sort = sort, + deviceId = deviceId, + targetCount = targetCount, + pageSize = normalizedLimit, + ) + } + } + }.awaitAll() + } + val mergedRuns = sortCommunityRuns( + slices.flatMap { it.runs }.distinctBy { it.communityIdentityKey() }, + sort, + ) + val startIndex = (normalizedPage.toLong() * normalizedLimit).toInt() + CommunityConfigPage( + runs = mergedRuns.drop(startIndex).take(normalizedLimit), + total = slices.sumOf { it.total.toLong() }.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + page = normalizedPage, + hasMore = mergedRuns.size > endExclusive || slices.any { it.hasMore }, + ) + } + result.also { configPageCache.put(cacheKey, it) } + } + + suspend fun fetchCompatibleConfigs( + gameId: Int, + currentGpu: String, + sort: CommunityConfigSort, + page: Int, + ): CommunityConfigPage = withContext(Dispatchers.IO) { + val currentCompatibility = communityGpuCompatibility(currentGpu) + if (currentCompatibility == CommunityGpuCompatibility.UNKNOWN) { + return@withContext CommunityConfigPage(emptyList(), 0, page.coerceAtLeast(0), false) + } + + val normalizedPage = page.coerceAtLeast(0) + val cacheKey = ConfigCacheKey( + gameId = gameId, + gpu = "", + sort = sort, + page = normalizedPage, + limit = MAX_API_PAGE_SIZE, + deviceIds = emptyList(), + compatibility = currentCompatibility, + ) + configPageCache.get(cacheKey)?.let { return@withContext it } + val gpuQuery = "Adreno".takeIf { + currentCompatibility == CommunityGpuCompatibility.ADRENO_STANDARD || + currentCompatibility == CommunityGpuCompatibility.ADRENO_ELITE + } + val result = fetchConfigPage( + gameId = gameId, + gpu = gpuQuery, + sort = sort, + page = normalizedPage, + limit = MAX_API_PAGE_SIZE, + deviceId = null, + ) + val compatibleRuns = sortCommunityRuns( + result.runs.filter { communityGpuCompatibility(it.device.gpu) == currentCompatibility }, + sort, + ) + CommunityConfigPage( + runs = compatibleRuns, + total = compatibleRuns.size, + page = result.page, + hasMore = result.hasMore, + ).also { configPageCache.put(cacheKey, it) } + } + + private data class DeviceConfigSlice( + val runs: List, + val total: Int, + val hasMore: Boolean, + ) + + private fun fetchDeviceConfigSlice( + gameId: Int, + sort: CommunityConfigSort, + deviceId: Int, + targetCount: Int, + pageSize: Int, + ): DeviceConfigSlice { + val runs = LinkedHashMap() + var nextPage = 0 + var total = 0 + var hasMore: Boolean + do { + val result = fetchConfigPage( + gameId = gameId, + gpu = null, + sort = sort, + page = nextPage, + limit = pageSize, + deviceId = deviceId, + ) + result.runs.forEach { runs.putIfAbsent(it.communityIdentityKey(), it) } + total = maxOf(total, result.total) + nextPage++ + val maximumPages = (total.toLong() + pageSize - 1) / pageSize + hasMore = result.hasMore && + nextPage < maximumPages && + nextPage < MAX_DEVICE_SLICE_PAGES + } while (runs.size < targetCount && hasMore) + + return DeviceConfigSlice( + runs = runs.values.toList(), + total = total, + hasMore = hasMore, + ) + } + + private fun fetchConfigPage( + gameId: Int, + gpu: String?, + sort: CommunityConfigSort, + page: Int, + limit: Int, + deviceId: Int?, + ): CommunityConfigPage { + val normalizedPage = page.coerceAtLeast(0) + val normalizedLimit = limit.coerceIn(1, MAX_API_PAGE_SIZE) + val validDeviceId = deviceId?.takeIf { it > 0 } + val cacheKey = ConfigCacheKey( + gameId = gameId, + gpu = gpu.orEmpty().trim().lowercase(Locale.ENGLISH), + sort = sort, + page = normalizedPage, + limit = normalizedLimit, + deviceIds = listOfNotNull(validDeviceId), + compatibility = null, + ) + configPageCache.get(cacheKey)?.let { return it } + + val urlBuilder = endpoint("api/compatibility") + .addQueryParameter("gameId", gameId.toString()) + .addQueryParameter("sort", sort.apiValue) + .addQueryParameter("dir", "desc") + .addQueryParameter("page", normalizedPage.toString()) + .addQueryParameter("limit", normalizedLimit.toString()) + if (validDeviceId != null) { + urlBuilder.addQueryParameter("deviceId", validDeviceId.toString()) + } else { + gpu?.trim()?.takeIf { it.isNotEmpty() }?.let { + urlBuilder.addQueryParameter("gpu", it) + } + } + return parseConfigPage(execute(urlBuilder.build().toString())).also { + configPageCache.put(cacheKey, it) + } + } + + private fun endpoint(path: String) = baseUrl + .toHttpUrlOrNull() + ?.newBuilder() + ?.addPathSegments(path) + ?: throw CommunityConfigApiException("Invalid compatibility API URL") + + private fun execute(url: String): String { + val request = Request.Builder().url(url).get().build() + requestThrottle.awaitPermit() + try { + return client.newCall(request).execute().use { response -> + val body = readBoundedBody(response.body) + if (!response.isSuccessful) { + if (response.code == 429) { + val retryAfterMillis = parseCommunityRetryAfterMillis( + response.header("Retry-After"), + ) + ?: DEFAULT_RATE_LIMIT_COOLDOWN_MILLIS + requestThrottle.postpone(retryAfterMillis) + } + throw CommunityConfigApiException( + message = parseErrorMessage(body) + .ifBlank { "Compatibility service returned HTTP ${response.code}" }, + statusCode = response.code, + ) + } + body + } + } catch (error: CancellationException) { + throw error + } catch (error: CommunityConfigApiException) { + throw error + } catch (error: IOException) { + throw CommunityConfigApiException("Unable to reach the compatibility service", cause = error) + } + } + + private fun readBoundedBody(body: okhttp3.ResponseBody?): String { + body ?: return "" + if (body.contentLength() > MAX_RESPONSE_BYTES) { + throw CommunityConfigApiException("Compatibility response is too large") + } + val source = body.source() + source.request(MAX_RESPONSE_BYTES + 1) + if (source.buffer.size > MAX_RESPONSE_BYTES) { + throw CommunityConfigApiException("Compatibility response is too large") + } + return source.readString(Charsets.UTF_8) + } + + private fun parseGames(body: String): List { + return try { + val games = JSONObject(body).optJSONArray("games") ?: JSONArray() + buildList { + for (index in 0 until games.length()) { + val game = games.optJSONObject(index) ?: continue + val id = game.optInt("id", 0) + val name = game.cleanString("name") + if (id > 0 && name.isNotEmpty()) add(CommunityGame(id, name)) + } + } + } catch (error: Exception) { + throw CommunityConfigApiException("Invalid game search response", cause = error) + } + } + + private fun parseDevices(body: String): List { + return try { + val devices = JSONObject(body).optJSONArray("devices") ?: JSONArray() + buildList { + for (index in 0 until devices.length()) { + parseDevice(devices.optJSONObject(index)) + ?.takeIf { it.id > 0 } + ?.let(::add) + } + } + } catch (error: Exception) { + throw CommunityConfigApiException("Invalid device response", cause = error) + } + } + + private fun parseConfigPage(body: String): CommunityConfigPage { + return try { + val root = JSONObject(body) + val runsJson = root.optJSONArray("runs") ?: JSONArray() + val rawRunCount = runsJson.length() + val runs = buildList { + for (index in 0 until runsJson.length()) { + parseRun(runsJson.optJSONObject(index))?.let(::add) + } + } + val page = root.optInt("page", 0).coerceAtLeast(0) + val pageSize = root.optInt("pageSize", rawRunCount).coerceAtLeast(rawRunCount).coerceAtLeast(1) + val total = root.optInt("total", rawRunCount).coerceAtLeast(rawRunCount) + CommunityConfigPage( + runs = runs, + total = total, + page = page, + hasMore = rawRunCount > 0 && (page + 1L) * pageSize < total, + ) + } catch (error: CommunityConfigApiException) { + throw error + } catch (error: Exception) { + throw CommunityConfigApiException("Invalid compatibility response", cause = error) + } + } + + private fun parseRun(run: JSONObject?): CommunityConfigRun? { + run ?: return null + val id = run.optLong("id", 0L) + if (id <= 0) return null + val configObject = when (val value = run.opt("configs")) { + is JSONObject -> value + is String -> runCatching { JSONObject(value) }.getOrNull() + else -> null + } ?: return null + val config = runCatching { + Json.parseToJsonElement(configObject.toString()).jsonObject + }.getOrNull() ?: return null + val safeConfig = sanitizeCommunityConfig(config) + if (!isValidCommunityConfig(safeConfig)) return null + return CommunityConfigRun( + id = id, + rating = run.optInt("rating", 0).coerceIn(0, 5), + averageFps = if (run.isNull("avgFps")) null else run.optDouble("avgFps").takeIf { it.isFinite() }, + tags = run.optJSONArray("tags").toStringList(), + notes = run.cleanString("notes", MAX_NOTES_CHARS), + config = safeConfig, + createdAt = run.cleanString("createdAt"), + appVersion = run.cleanString("appVersion"), + sessionLengthSeconds = parseSessionLength(run, configObject), + gameStore = parseGameStore(run, configObject), + device = parseDevice(run.optJSONObject("device"), run.optInt("deviceId", 0)) + ?: CommunityConfigDevice(0, "", "", "", ""), + ) + } + + private fun parseSessionLength(run: JSONObject, config: JSONObject): Long? { + val sessionMetadata = config.optJSONObject("sessionMetadata") + return sequenceOf( + run.optPositiveLong("sessionLengthSec"), + run.optPositiveLong("session_length_sec"), + sessionMetadata?.optPositiveLong("sessionLengthSec"), + sessionMetadata?.optPositiveLong("session_length_sec"), + ).filterNotNull().firstOrNull() + } + + private fun parseGameStore(run: JSONObject, config: JSONObject): String { + val explicitStore = sequenceOf("gameStore", "game_store", "store") + .map { run.cleanString(it) } + .firstOrNull { it.isNotEmpty() } + return normalizeCommunityGameStore(explicitStore.orEmpty()).ifEmpty { + inferCommunityGameStore(config.cleanString("id")) + } + } + + private fun parseDevice(device: JSONObject?, fallbackId: Int = 0): CommunityConfigDevice? { + device ?: return null + val id = device.optInt("id", fallbackId) + val model = device.cleanString("model") + if (model.isEmpty()) return null + return CommunityConfigDevice( + id = id, + model = model, + gpu = device.cleanString("gpu"), + androidVersion = device.cleanString("androidVer"), + soc = device.cleanString("soc"), + ) + } + + private fun parseErrorMessage(body: String): String { + return runCatching { + val root = JSONObject(body) + when (val error = root.opt("error")) { + is JSONObject -> error.optString("message") + is String -> error + else -> root.optString("message") + } + }.getOrDefault("").trim().take(MAX_METADATA_CHARS) + } +} + +internal fun parseCommunityRetryAfterMillis( + value: String?, + nowEpochMillis: Long = System.currentTimeMillis(), +): Long? { + val normalized = value?.trim().orEmpty() + if (normalized.isEmpty()) return null + + normalized.toLongOrNull()?.takeIf { it > 0L }?.let { seconds -> + return TimeUnit.SECONDS.toMillis(seconds.coerceAtMost(60L)) + } + + val retryAtMillis = runCatching { + ZonedDateTime.parse(normalized, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + }.getOrNull() ?: return null + return (retryAtMillis - nowEpochMillis) + .takeIf { it > 0L } + ?.coerceIn(1_000L, MAX_RATE_LIMIT_COOLDOWN_MILLIS) +} + +internal fun selectCommunityGame(query: String, games: List): CommunityGame? { + if (games.isEmpty()) return null + val normalizedQuery = normalizeCommunityGameName(query) + return games.firstOrNull { normalizeCommunityGameName(it.name) == normalizedQuery } +} + +internal fun communityDeviceQuery(manufacturer: String, model: String): String { + val cleanManufacturer = manufacturer.trim() + val cleanModel = model.trim() + if (cleanManufacturer.isEmpty()) return cleanModel + if (cleanModel.startsWith(cleanManufacturer, ignoreCase = true)) return cleanModel + return "$cleanManufacturer $cleanModel".trim() +} + +internal fun selectCommunityDevices( + devices: List, + manufacturer: String, + model: String, + currentGpu: String, + androidVersion: String, +): List { + val canonicalGpu = canonicalCommunityGpu(currentGpu) + val canonicalAndroid = canonicalCommunityAndroid(androidVersion) + val fullModel = canonicalCommunityDeviceModel(communityDeviceQuery(manufacturer, model)) + val shortModel = canonicalCommunityDeviceModel(model) + return devices + .asSequence() + .filter { device -> + val candidate = canonicalCommunityDeviceModel(device.model) + candidate == fullModel || candidate == shortModel + } + .filter { device -> + val deviceGpu = canonicalCommunityGpu(device.gpu) + canonicalGpu.isEmpty() || deviceGpu.isEmpty() || deviceGpu == canonicalGpu + } + .distinctBy { it.id } + .sortedWith( + compareByDescending { + canonicalGpu.isNotEmpty() && canonicalCommunityGpu(it.gpu) == canonicalGpu + }.thenByDescending { + canonicalAndroid.isNotEmpty() && canonicalCommunityAndroid(it.androidVersion) == canonicalAndroid + }.thenByDescending { it.id }, + ) + .toList() +} + +internal fun communityConfigMatchType(currentGpu: String, configGpu: String): String { + val current = canonicalCommunityGpu(currentGpu) + val candidate = canonicalCommunityGpu(configGpu) + val currentCompatibility = communityGpuCompatibility(currentGpu) + val candidateCompatibility = communityGpuCompatibility(configGpu) + return when { + current.isNotEmpty() && current == candidate -> "exact_gpu_match" + currentCompatibility == candidateCompatibility && + (currentCompatibility == CommunityGpuCompatibility.ADRENO_STANDARD || + currentCompatibility == CommunityGpuCompatibility.ADRENO_ELITE) -> "gpu_family_match" + else -> "fallback_match" + } +} + +internal fun communityGpuCompatibility(value: String): CommunityGpuCompatibility { + return when (val gpu = canonicalCommunityGpu(value)) { + "" -> CommunityGpuCompatibility.UNKNOWN + else -> when { + gpu.matches(Regex("adreno:[67][0-9]{2}")) -> CommunityGpuCompatibility.ADRENO_STANDARD + gpu == "adreno:a12" || gpu.matches(Regex("adreno:8[3-5][0-9]")) -> { + CommunityGpuCompatibility.ADRENO_ELITE + } + else -> CommunityGpuCompatibility.OTHER + } + } +} + +internal fun normalizeCommunityGameStore(value: String): String { + val normalized = value.lowercase(Locale.ENGLISH).replace(Regex("[^a-z0-9]+"), "") + return when (normalized) { + "steam" -> "steam" + "epic", "epicgames", "epicgamesstore" -> "epic" + "gog", "gogcom" -> "gog" + "amazon", "amazongames" -> "amazon" + "custom", "customgame" -> "custom" + else -> value.trim().take(MAX_METADATA_CHARS) + } +} + +private fun inferCommunityGameStore(configId: String): String { + val normalized = configId.uppercase(Locale.ENGLISH) + return when { + normalized.startsWith("STEAM_") -> "steam" + normalized.startsWith("EPIC_") -> "epic" + normalized.startsWith("GOG_") -> "gog" + normalized.startsWith("AMAZON_") -> "amazon" + normalized.startsWith("CUSTOM_GAME_") -> "custom" + else -> "" + } +} + +internal fun canonicalCommunityGpu(value: String): String { + val cleaned = value + .lowercase(Locale.ENGLISH) + .replace("(tm)", " ") + .replace(Regex("\\s+"), " ") + .trim() + if (cleaned.isEmpty()) return "" + if (cleaned.contains("unknown") || cleaned == "n/a") return "" + + Regex("\\badreno[ -]*([a-z]?\\d+)\\b").find(cleaned)?.let { + return "adreno:${it.groupValues[1]}" + } + Regex("\\b(mali|immortalis)[ -]*([a-z]\\d+)\\b").find(cleaned)?.let { + return "arm:${it.groupValues[2]}" + } + Regex("\\bxclipse[ -]*(\\d+)\\b").find(cleaned)?.let { + return "xclipse:${it.groupValues[1]}" + } + return cleaned.replace(Regex("[^a-z0-9]+"), "") +} + +private fun normalizeCommunityGameName(value: String): String = value + .lowercase(Locale.ENGLISH) + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + .replace(Regex("\\s+"), " ") + +private fun canonicalCommunityAndroid(value: String): String = value + .lowercase(Locale.ENGLISH) + .replace("android", "") + .trim() + +private fun canonicalCommunityDeviceModel(value: String): String = value + .lowercase(Locale.ENGLISH) + .replace(Regex("[^a-z0-9]+"), "") + +private val communityConfigAllowedKeys = setOf( + "graphicsDriver", + "graphicsDriverVersion", + "graphicsDriverConfig", + "dxwrapper", + "dxwrapperConfig", + "startupSelection", + "box64Version", + "box64Preset", + "containerVariant", + "wineVersion", + "emulator", + "fexcoreVersion", + "fexcoreTSOMode", + "fexcoreX87Mode", + "fexcoreMultiBlock", + "fexcorePreset", + "useLegacyDRM", + "audioDriver", + "wincomponents", + "videoMemorySize", + "execArgs", + "envVars", +) + +private val communityEnvironmentNamePattern = Regex("[A-Za-z_][A-Za-z0-9_]*") + +private val protectedCommunityEnvironmentNames = setOf( + "ANDROID_ALSA_SERVER", + "ANDROID_SYSVSHM_SERVER", + "BOX64_BASH", + "BOX64_PATH", + "BOX86_BASH", + "BOX86_PATH", + "GUEST_PROGRAM_LAUNCHER_COMMAND", + "HOME", + "PATH", + "PREFIX", + "PROOT_LOADER", + "PROOT_TMP_DIR", + "TMPDIR", + "WINELOADER", + "WINEDLLPATH", + "WINEPATH", + "WINEPREFIX", + "WINESERVER", +) + +private fun isProtectedCommunityEnvironmentName(name: String): Boolean { + val normalized = name.uppercase(Locale.ENGLISH) + return normalized in protectedCommunityEnvironmentNames || + normalized.startsWith("LD_") || + normalized.startsWith("DYLD_") || + normalized.startsWith("BOX64_LD_") || + normalized.startsWith("BOX86_LD_") +} + +internal fun sanitizeCommunityEnvironmentVariables(value: String): String { + val environmentVariables = EnvVars(value) + val sanitized = EnvVars() + var accepted = 0 + for (name in environmentVariables) { + val variableValue = environmentVariables.get(name) + if (accepted >= MAX_ENVIRONMENT_VARIABLES) break + if (!communityEnvironmentNamePattern.matches(name) || + name.length > MAX_ENVIRONMENT_NAME_CHARS || + variableValue.length > MAX_ENVIRONMENT_VALUE_CHARS || + variableValue.any { it == '\u0000' || it == '\r' || it == '\n' } || + isProtectedCommunityEnvironmentName(name) + ) { + continue + } + sanitized.put(name, variableValue) + accepted++ + } + return sanitized.toString() +} + +internal fun sanitizeCommunityConfig(config: JsonObject): JsonObject = JsonObject( + buildMap { + config.forEach { (key, value) -> + if (key !in communityConfigAllowedKeys || value !is JsonPrimitive) return@forEach + val content = value.contentOrNull ?: return@forEach + if (content.length > MAX_CONFIG_VALUE_CHARS) return@forEach + + when (key) { + "execArgs" -> content + .takeIf { + it.length <= MAX_LAUNCH_ARGUMENT_CHARS && + it.none { char -> char == '\u0000' || char == '\r' || char == '\n' } + } + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { put(key, JsonPrimitive(it)) } + "envVars" -> sanitizeCommunityEnvironmentVariables(content) + .takeIf { it.isNotEmpty() } + ?.let { put(key, JsonPrimitive(it)) } + else -> put(key, value) + } + } + }, +) + +internal fun prepareCommunityConfigForApply( + config: JsonObject, + applyLaunchArguments: Boolean, + applyEnvironmentVariables: Boolean, +): JsonObject = JsonObject( + sanitizeCommunityConfig(config).filterKeys { key -> + (key != "execArgs" || applyLaunchArguments) && + (key != "envVars" || applyEnvironmentVariables) + }, +) + +internal fun isValidCommunityConfig( + config: JsonObject, + allowGlibc: Boolean = !BuildConfig.MODERN_ANDROID, +): Boolean { + fun value(key: String) = (config[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + + val variant = value("containerVariant") + if (!variant.equals(Container.BIONIC, ignoreCase = true) && + !variant.equals(Container.GLIBC, ignoreCase = true) + ) { + return false + } + if (!allowGlibc && variant.equals(Container.GLIBC, ignoreCase = true)) return false + if (!variant.equals(Container.GLIBC, ignoreCase = true) && value("wineVersion").isEmpty()) return false + return value("dxwrapper").isNotEmpty() && value("dxwrapperConfig").isNotEmpty() +} + +internal fun sortCommunityRuns( + runs: List, + sort: CommunityConfigSort, +): List { + fun CommunityConfigRun.createdAtMillis(): Long = runCatching { + OffsetDateTime.parse(createdAt).toInstant().toEpochMilli() + }.getOrDefault(Long.MIN_VALUE) + + val comparator = when (sort) { + CommunityConfigSort.HIGHEST_RATED -> compareByDescending { it.rating } + .thenByDescending { it.createdAtMillis() } + .thenByDescending { it.id } + CommunityConfigSort.NEWEST -> compareByDescending { it.createdAtMillis() } + .thenByDescending { it.id } + } + return runs.sortedWith(comparator) +} + +private fun JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return buildList { + for (index in 0 until minOf(length(), MAX_TAGS)) { + if (isNull(index)) continue + optString(index).trim().take(MAX_TAG_CHARS).takeIf { it.isNotEmpty() }?.let(::add) + } + } +} + +private fun JSONObject.cleanString(key: String, maxLength: Int = MAX_METADATA_CHARS): String { + if (isNull(key)) return "" + return optString(key).trim().take(maxLength) +} + +private fun JSONObject.optPositiveLong(key: String): Long? { + if (!has(key) || isNull(key)) return null + return when (val value = opt(key)) { + is Number -> value.toLong() + is String -> value.trim().toLongOrNull() + else -> null + }?.takeIf { it > 0 } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/CommunityConfigsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/CommunityConfigsDialog.kt new file mode 100644 index 0000000000..fd63e4f26b --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/CommunityConfigsDialog.kt @@ -0,0 +1,1059 @@ +package app.gamenative.ui.component.dialog + +import android.os.Build +import android.text.format.DateUtils +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.gamenative.R +import app.gamenative.api.CommunityConfigDevice +import app.gamenative.api.CommunityConfigRun +import app.gamenative.api.CommunityConfigService +import app.gamenative.api.CommunityConfigSort +import app.gamenative.api.CommunityGame +import app.gamenative.api.canonicalCommunityGpu +import app.gamenative.api.communityConfigMatchType +import app.gamenative.api.communityIdentityKey +import app.gamenative.api.sortCommunityRuns +import app.gamenative.utils.BestConfigService +import com.winlator.core.GPUInformation +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private enum class CommunityHardwareScope { + CURRENT_DEVICE, + CURRENT_GPU, + COMPATIBLE_GPUS, +} + +data class CommunityConfigApplyOptions( + val applyLaunchArguments: Boolean, + val applyEnvironmentVariables: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommunityConfigsDialog( + visible: Boolean, + gameName: String, + currentLaunchArguments: String, + currentEnvironmentVariables: String, + onDismissRequest: () -> Unit, + onApply: (CommunityConfigRun, String, CommunityConfigApplyOptions) -> Unit, + service: CommunityConfigService = CommunityConfigService.shared, +) { + if (!visible) return + + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var detectedGpu by remember(gameName) { mutableStateOf("") } + var detectedDevices by remember(gameName) { mutableStateOf(emptyList()) } + var resolvedGame by remember(gameName) { mutableStateOf(null) } + var sort by remember(gameName) { mutableStateOf(CommunityConfigSort.HIGHEST_RATED) } + var hardwareScope by remember(gameName) { mutableStateOf(CommunityHardwareScope.CURRENT_DEVICE) } + var runs by remember(gameName) { mutableStateOf(emptyList()) } + var total by remember(gameName) { mutableIntStateOf(0) } + var hasMore by remember(gameName) { mutableStateOf(false) } + var currentPage by remember(gameName) { mutableIntStateOf(0) } + var loading by remember(gameName) { mutableStateOf(true) } + var loadingMore by remember(gameName) { mutableStateOf(false) } + var errorMessage by remember(gameName) { mutableStateOf(null) } + var selectedRun by remember(gameName) { mutableStateOf(null) } + var lookupKey by remember(gameName) { mutableIntStateOf(0) } + var configRefreshKey by remember(gameName) { mutableIntStateOf(0) } + var requestGeneration by remember(gameName) { mutableIntStateOf(0) } + + LaunchedEffect(visible, gameName, lookupKey) { + val generation = ++requestGeneration + loading = true + loadingMore = false + errorMessage = null + runs = emptyList() + total = 0 + hasMore = false + currentPage = 0 + resolvedGame = null + try { + val renderer = withContext(Dispatchers.IO) { + runCatching { GPUInformation.getRenderer(context) }.getOrNull().orEmpty().trim() + } + val gpu = renderer.takeIf { canonicalCommunityGpu(it).isNotEmpty() }.orEmpty() + val devices = try { + service.findDevices( + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + gpu = gpu, + androidVersion = Build.VERSION.RELEASE, + ) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + emptyList() + } + val game = service.findGame(gameName) + if (generation != requestGeneration) return@LaunchedEffect + + detectedGpu = gpu + detectedDevices = devices + if (devices.isEmpty() && + detectedGpu.isNotBlank() && + hardwareScope == CommunityHardwareScope.CURRENT_DEVICE + ) { + hardwareScope = CommunityHardwareScope.CURRENT_GPU + } + if (game == null) { + errorMessage = context.getString(R.string.community_config_game_not_found, gameName) + loading = false + } else { + resolvedGame = game + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (generation == requestGeneration) { + errorMessage = context.getString( + R.string.community_config_load_failed, + error.message ?: context.getString(R.string.community_config_unknown_error), + ) + loading = false + } + } + } + + LaunchedEffect( + visible, + resolvedGame?.id, + detectedGpu, + detectedDevices.map { it.id }, + sort, + hardwareScope, + lookupKey, + configRefreshKey, + ) { + val game = resolvedGame ?: return@LaunchedEffect + val generation = ++requestGeneration + loading = true + loadingMore = false + errorMessage = null + runs = emptyList() + total = 0 + hasMore = false + currentPage = 0 + if (detectedDevices.isEmpty() && detectedGpu.isBlank()) { + loading = false + return@LaunchedEffect + } + try { + val result = if (hardwareScope == CommunityHardwareScope.COMPATIBLE_GPUS) { + service.fetchCompatibleConfigs(game.id, detectedGpu, sort, page = 0) + } else { + service.fetchConfigs( + gameId = game.id, + gpu = detectedGpu.takeIf { hardwareScope == CommunityHardwareScope.CURRENT_GPU }, + sort = sort, + page = 0, + deviceIds = detectedDevices.map { it.id } + .takeIf { hardwareScope == CommunityHardwareScope.CURRENT_DEVICE } + .orEmpty(), + ) + } + if (generation != requestGeneration) return@LaunchedEffect + runs = result.runs + total = result.total + currentPage = result.page + hasMore = result.hasMore + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (generation == requestGeneration) { + errorMessage = context.getString( + R.string.community_config_load_failed, + error.message ?: context.getString(R.string.community_config_unknown_error), + ) + } + } finally { + if (generation == requestGeneration) loading = false + } + } + + fun loadMore() { + val game = resolvedGame ?: return + if (loadingMore || !hasMore) return + val generation = requestGeneration + val requestedSort = sort + val requestedScope = hardwareScope + val requestedGpu = detectedGpu.takeIf { requestedScope != CommunityHardwareScope.CURRENT_DEVICE } + val requestedDeviceIds = detectedDevices.map { it.id } + .takeIf { requestedScope == CommunityHardwareScope.CURRENT_DEVICE } + .orEmpty() + val requestedPage = currentPage + 1 + fun requestIsCurrent(): Boolean = generation == requestGeneration && + sort == requestedSort && + hardwareScope == requestedScope && + resolvedGame?.id == game.id && + detectedGpu.takeIf { requestedScope != CommunityHardwareScope.CURRENT_DEVICE } == requestedGpu && + detectedDevices.map { it.id } + .takeIf { requestedScope == CommunityHardwareScope.CURRENT_DEVICE } + .orEmpty() == requestedDeviceIds + loadingMore = true + coroutineScope.launch { + errorMessage = null + try { + val result = if (requestedScope == CommunityHardwareScope.COMPATIBLE_GPUS) { + service.fetchCompatibleConfigs(game.id, requestedGpu.orEmpty(), requestedSort, requestedPage) + } else { + service.fetchConfigs( + gameId = game.id, + gpu = requestedGpu, + sort = requestedSort, + page = requestedPage, + deviceIds = requestedDeviceIds, + ) + } + if (requestIsCurrent()) { + val mergedRuns = sortCommunityRuns( + (runs + result.runs).distinctBy { it.communityIdentityKey() }, + requestedSort, + ) + runs = mergedRuns + total = if (requestedScope == CommunityHardwareScope.COMPATIBLE_GPUS) { + mergedRuns.size + } else { + result.total + } + currentPage = result.page + hasMore = result.hasMore + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (requestIsCurrent()) { + errorMessage = context.getString( + R.string.community_config_load_failed, + error.message ?: context.getString(R.string.community_config_unknown_error), + ) + } + } finally { + if (requestIsCurrent()) loadingMore = false + } + } + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.community_config_title)) }, + navigationIcon = { + IconButton(onClick = onDismissRequest) { + Icon(Icons.Default.Close, stringResource(R.string.community_config_close)) + } + }, + actions = { + IconButton( + onClick = { + service.clearConfigCache() + configRefreshKey++ + }, + enabled = !loading && !loadingMore, + ) { + Icon(Icons.Default.Refresh, stringResource(R.string.community_config_refresh)) + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + CommunityConfigHeader( + gameName = resolvedGame?.name ?: gameName, + deviceName = detectedDevices.firstOrNull()?.model + ?: listOf(Build.MANUFACTURER, Build.MODEL).joinToString(" ").trim(), + gpuName = detectedGpu, + total = total, + ) + CommunityConfigControls( + sort = sort, + hardwareScope = hardwareScope, + deviceAvailable = detectedDevices.isNotEmpty(), + gpuAvailable = detectedGpu.isNotBlank(), + enabled = !loading && !loadingMore, + onSortChange = { sort = it }, + onHardwareScopeChange = { hardwareScope = it }, + ) + HorizontalDivider() + + Box(modifier = Modifier.fillMaxSize()) { + when { + loading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + errorMessage != null && runs.isEmpty() -> CommunityConfigError( + message = errorMessage.orEmpty(), + onRetry = { + if (resolvedGame == null) { + lookupKey++ + } else { + service.clearConfigCache() + configRefreshKey++ + } + }, + modifier = Modifier.align(Alignment.Center), + ) + runs.isEmpty() -> CommunityConfigEmptyState( + hardwareScope = hardwareScope, + gpuAvailable = detectedGpu.isNotBlank(), + hasMore = hasMore, + loadingMore = loadingMore, + onBroaden = { + hardwareScope = if ( + hardwareScope == CommunityHardwareScope.CURRENT_DEVICE && + detectedGpu.isNotBlank() + ) { + CommunityHardwareScope.CURRENT_GPU + } else { + CommunityHardwareScope.COMPATIBLE_GPUS + } + }, + onLoadMore = ::loadMore, + modifier = Modifier.align(Alignment.Center), + ) + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = runs, + key = { it.communityIdentityKey() }, + ) { run -> + CommunityConfigListItem( + run = run, + onClick = { selectedRun = run }, + ) + HorizontalDivider() + } + if (errorMessage != null) { + item(key = "pagination-error") { + CommunityConfigError( + message = errorMessage.orEmpty(), + onRetry = ::loadMore, + modifier = Modifier.fillMaxWidth(), + ) + } + } else if (hasMore) { + item(key = "load-more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Button(onClick = ::loadMore, enabled = !loadingMore) { + if (loadingMore) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Default.ExpandMore, null) + } + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.community_config_load_more)) + } + } + } + } + } + } + } + } + } + } + + selectedRun?.let { run -> + val matchType = communityConfigMatchType(detectedGpu, run.device.gpu) + CommunityConfigPreviewDialog( + run = run, + matchType = matchType, + currentLaunchArguments = currentLaunchArguments, + currentEnvironmentVariables = currentEnvironmentVariables, + onDismissRequest = { selectedRun = null }, + onApply = { options -> + selectedRun = null + onApply(run, matchType, options) + }, + ) + } +} + +@Composable +private fun CommunityConfigHeader( + gameName: String, + deviceName: String, + gpuName: String, + total: Int, +) { + ListItem( + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + headlineContent = { + Text( + text = gameName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = listOf( + deviceName, + gpuName.ifBlank { stringResource(R.string.community_config_gpu_unknown) }, + ).filter { it.isNotBlank() }.joinToString(" | "), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + if (total > 0) Text(pluralStringResource(R.plurals.community_config_result_count, total, total)) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommunityConfigControls( + sort: CommunityConfigSort, + hardwareScope: CommunityHardwareScope, + deviceAvailable: Boolean, + gpuAvailable: Boolean, + enabled: Boolean, + onSortChange: (CommunityConfigSort) -> Unit, + onHardwareScopeChange: (CommunityHardwareScope) -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + val compact = maxWidth < 600.dp + val content: @Composable (Modifier) -> Unit = { modifier -> + CommunitySortControl(sort, enabled, onSortChange, modifier) + CommunityHardwareControl( + hardwareScope, + deviceAvailable, + gpuAvailable, + enabled, + onHardwareScopeChange, + modifier, + ) + } + if (compact) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + content(Modifier.fillMaxWidth()) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + content(Modifier.weight(1f)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommunitySortControl( + selected: CommunityConfigSort, + enabled: Boolean, + onSelected: (CommunityConfigSort) -> Unit, + modifier: Modifier, +) { + val options = CommunityConfigSort.entries + SingleChoiceSegmentedButtonRow(modifier = modifier) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = selected == option, + onClick = { onSelected(option) }, + enabled = enabled, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + label = { + Text( + if (option == CommunityConfigSort.HIGHEST_RATED) { + stringResource(R.string.community_config_highest_rated) + } else { + stringResource(R.string.community_config_newest) + }, + maxLines = 1, + ) + }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommunityHardwareControl( + selected: CommunityHardwareScope, + deviceAvailable: Boolean, + gpuAvailable: Boolean, + enabled: Boolean, + onSelected: (CommunityHardwareScope) -> Unit, + modifier: Modifier, +) { + val options = CommunityHardwareScope.entries + SingleChoiceSegmentedButtonRow(modifier = modifier) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = selected == option, + onClick = { onSelected(option) }, + enabled = enabled && when (option) { + CommunityHardwareScope.CURRENT_DEVICE -> deviceAvailable + CommunityHardwareScope.CURRENT_GPU -> gpuAvailable + CommunityHardwareScope.COMPATIBLE_GPUS -> gpuAvailable + }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + label = { + Text( + when (option) { + CommunityHardwareScope.CURRENT_DEVICE -> { + stringResource(R.string.community_config_same_device) + } + CommunityHardwareScope.CURRENT_GPU -> { + stringResource(R.string.community_config_this_gpu) + } + CommunityHardwareScope.COMPATIBLE_GPUS -> { + stringResource(R.string.community_config_compatible_gpus) + } + }, + maxLines = 1, + ) + }, + ) + } + } +} + +@Composable +private fun CommunityConfigListItem( + run: CommunityConfigRun, + onClick: () -> Unit, +) { + val performance = buildList { + add(stringResource(R.string.community_config_rating_value, run.rating)) + run.averageFps?.let { + add(stringResource(R.string.community_config_fps_value, String.format(Locale.getDefault(), "%.1f", it))) + } + formatCommunityConfigDate(run.createdAt).takeIf { it.isNotBlank() }?.let(::add) + }.joinToString(" | ") + val hardware = listOf(run.device.model, run.device.gpu) + .filter { it.isNotBlank() } + .joinToString(" | ") + + ListItem( + modifier = Modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + leadingContent = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text(run.rating.toString(), style = MaterialTheme.typography.labelMedium) + } + }, + headlineContent = { + Text( + text = performance, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (hardware.isNotBlank()) { + Text(hardware, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (run.notes.isNotBlank()) { + Text( + text = run.notes, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + trailingContent = { Icon(Icons.Default.ChevronRight, null) }, + ) +} + +@Composable +private fun CommunityConfigError( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text(message, color = MaterialTheme.colorScheme.error) + Button(onClick = onRetry) { Text(stringResource(R.string.community_config_retry)) } + } +} + +@Composable +private fun CommunityConfigEmptyState( + hardwareScope: CommunityHardwareScope, + gpuAvailable: Boolean, + hasMore: Boolean, + loadingMore: Boolean, + onBroaden: () -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource( + when { + !gpuAvailable -> R.string.community_config_gpu_unknown + hardwareScope == CommunityHardwareScope.CURRENT_DEVICE -> { + R.string.community_config_no_device_results + } + hardwareScope == CommunityHardwareScope.CURRENT_GPU -> { + R.string.community_config_no_gpu_results + } + else -> R.string.community_config_no_compatible_results + }, + ), + style = MaterialTheme.typography.bodyLarge, + ) + if (gpuAvailable && hardwareScope != CommunityHardwareScope.COMPATIBLE_GPUS) { + Button(onClick = onBroaden) { + Text( + stringResource( + if (hardwareScope == CommunityHardwareScope.CURRENT_DEVICE && gpuAvailable) { + R.string.community_config_show_this_gpu + } else { + R.string.community_config_show_compatible_gpus + }, + ), + ) + } + } + if (hasMore) { + Button(onClick = onLoadMore, enabled = !loadingMore) { + if (loadingMore) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Default.ExpandMore, null) + } + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.community_config_load_more)) + } + } + } +} + +@Composable +private fun CommunityConfigPreviewDialog( + run: CommunityConfigRun, + matchType: String, + currentLaunchArguments: String, + currentEnvironmentVariables: String, + onDismissRequest: () -> Unit, + onApply: (CommunityConfigApplyOptions) -> Unit, +) { + val context = LocalContext.current + val launchArguments = run.configString("execArgs") + val environmentVariables = run.configString("envVars") + var dependencyNames by remember(run.id) { mutableStateOf?>(null) } + var dependencyCheckFailed by remember(run.id) { mutableStateOf(false) } + var applyLaunchArguments by remember(run.id) { mutableStateOf(false) } + var applyEnvironmentVariables by remember(run.id) { mutableStateOf(false) } + + LaunchedEffect(run.id, matchType) { + dependencyNames = null + dependencyCheckFailed = false + try { + dependencyNames = withContext(Dispatchers.IO) { + BestConfigService.resolveMissingManifestInstallRequests( + context = context, + configJson = run.config, + matchType = matchType, + matchedGpu = run.device.gpu, + preserveConfigValues = true, + ).map { it.entry.name }.distinct() + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + dependencyCheckFailed = true + dependencyNames = emptyList() + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.community_config_details_title)) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (matchType == "fallback_match") { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.community_config_other_gpu_warning), + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + CommunityConfigSectionTitle(stringResource(R.string.community_config_run_details)) + CommunityConfigDetailRow( + stringResource(R.string.community_config_rating), + stringResource(R.string.community_config_rating_value, run.rating), + ) + run.averageFps?.let { + CommunityConfigDetailRow( + stringResource(R.string.community_config_average_fps), + stringResource(R.string.community_config_fps_value, String.format(Locale.getDefault(), "%.1f", it)), + ) + } + run.sessionLengthSeconds?.let { + CommunityConfigDetailRow( + stringResource(R.string.community_config_session_length), + DateUtils.formatElapsedTime(it), + ) + } + formatCommunityConfigStore(run.gameStore).takeIf { it.isNotBlank() }?.let { + CommunityConfigDetailRow(stringResource(R.string.community_config_game_store), it) + } + CommunityConfigDetailRow( + stringResource(R.string.community_config_device), + run.device.model.ifBlank { stringResource(R.string.community_config_unknown) }, + ) + CommunityConfigDetailRow( + stringResource(R.string.community_config_gpu), + run.device.gpu.ifBlank { stringResource(R.string.community_config_gpu_unknown) }, + ) + run.device.androidVersion.takeIf { it.isNotBlank() }?.let { + CommunityConfigDetailRow(stringResource(R.string.community_config_android), it) + } + run.device.soc.takeIf { it.isNotBlank() }?.let { + CommunityConfigDetailRow(stringResource(R.string.community_config_soc), it) + } + run.appVersion.takeIf { it.isNotBlank() }?.let { + CommunityConfigDetailRow(stringResource(R.string.community_config_app_version), it) + } + formatCommunityConfigDate(run.createdAt).takeIf { it.isNotBlank() }?.let { + CommunityConfigDetailRow(stringResource(R.string.community_config_submitted), it) + } + + CommunityConfigSectionTitle(stringResource(R.string.community_config_settings)) + communityConfigSummary(run).forEach { (label, value) -> + CommunityConfigDetailRow(label, value) + } + + if (launchArguments.isNotBlank() || environmentVariables.isNotBlank()) { + CommunityConfigSectionTitle( + stringResource(R.string.community_config_additional_launch_settings), + ) + if (launchArguments.isNotBlank()) { + CommunityConfigApplyOption( + label = stringResource(R.string.community_config_apply_launch_arguments), + value = launchArguments, + checked = applyLaunchArguments, + replacesCurrentValue = currentLaunchArguments.isNotBlank() && + currentLaunchArguments != launchArguments, + onCheckedChange = { applyLaunchArguments = it }, + ) + } + if (environmentVariables.isNotBlank()) { + CommunityConfigApplyOption( + label = stringResource(R.string.community_config_apply_environment_variables), + value = environmentVariables, + checked = applyEnvironmentVariables, + replacesCurrentValue = currentEnvironmentVariables.isNotBlank() && + currentEnvironmentVariables != environmentVariables, + onCheckedChange = { applyEnvironmentVariables = it }, + ) + } + } + + CommunityConfigSectionTitle(stringResource(R.string.community_config_dependencies)) + when { + dependencyNames == null -> CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + dependencyCheckFailed -> Text( + stringResource(R.string.community_config_dependencies_apply_check), + style = MaterialTheme.typography.bodySmall, + ) + dependencyNames.orEmpty().isEmpty() -> Text( + stringResource(R.string.community_config_no_downloads), + style = MaterialTheme.typography.bodySmall, + ) + else -> Text( + stringResource( + R.string.community_config_downloads_required, + dependencyNames.orEmpty().joinToString("\n"), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + + if (run.tags.isNotEmpty()) { + CommunityConfigSectionTitle(stringResource(R.string.community_config_tags)) + Text(run.tags.joinToString(" | "), style = MaterialTheme.typography.bodyMedium) + } + if (run.notes.isNotBlank()) { + CommunityConfigSectionTitle(stringResource(R.string.community_config_notes)) + Text(run.notes, style = MaterialTheme.typography.bodyMedium) + } + } + }, + confirmButton = { + Button( + onClick = { + onApply( + CommunityConfigApplyOptions( + applyLaunchArguments = applyLaunchArguments, + applyEnvironmentVariables = applyEnvironmentVariables, + ), + ) + }, + ) { + Text(stringResource(R.string.community_config_apply)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun CommunityConfigApplyOption( + label: String, + value: String, + checked: Boolean, + replacesCurrentValue: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + Column( + modifier = Modifier.padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (replacesCurrentValue) { + Text( + text = stringResource(R.string.community_config_replaces_existing_value), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} + +@Composable +private fun formatCommunityConfigStore(value: String): String { + return when (value) { + "steam" -> "Steam" + "epic" -> "Epic Games Store" + "gog" -> "GOG" + "amazon" -> "Amazon Games" + "custom" -> stringResource(R.string.community_config_custom_game) + else -> value + } +} + +@Composable +private fun CommunityConfigSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun CommunityConfigDetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.38f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(0.62f), + ) + } +} + +@Composable +private fun communityConfigSummary(run: CommunityConfigRun): List> { + val context = LocalContext.current + return buildList { + fun addValue(label: Int, key: String) { + run.configString(key).takeIf { it.isNotBlank() }?.let { + add(context.getString(label) to it) + } + } + addValue(R.string.community_config_container, "containerVariant") + addValue(R.string.community_config_wine, "wineVersion") + addValue(R.string.community_config_emulator, "emulator") + addValue(R.string.community_config_wrapper, "dxwrapper") + addValue(R.string.community_config_box64_preset, "box64Preset") + run.configString("graphicsDriverConfig") + .settingValue("version") + .takeIf { it.isNotBlank() } + ?.let { add(context.getString(R.string.community_config_driver) to it) } + } +} + +private fun String.settingValue(key: String): String { + return split(',', ';') + .firstOrNull { it.substringBefore('=').trim().equals(key, ignoreCase = true) } + ?.substringAfter('=', "") + ?.trim() + .orEmpty() +} + +private fun formatCommunityConfigDate(value: String): String { + if (value.isBlank()) return "" + return runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + }.getOrDefault(value.substringBefore('T')) +} diff --git a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt index 1f846481e9..81d890a8d9 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -14,6 +14,7 @@ enum class AppOptionMenuType(@StringRes val title: Int) { SubmitFeedback(R.string.option_submit_feedback), ResetDrm(R.string.option_reset_drm), UseKnownConfig(R.string.option_use_known_config), + BrowseCommunityConfigs(R.string.option_browse_community_configs), ImportConfig(R.string.import_config), ExportConfig(R.string.export_config), ImportSaves(R.string.option_import_saves), diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 03d6f40f59..00483a8c46 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -26,33 +26,40 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import app.gamenative.PluviaApp import app.gamenative.R +import app.gamenative.api.isValidCommunityConfig +import app.gamenative.api.prepareCommunityConfigForApply import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.mods.ModContainerResolver import app.gamenative.mods.NexusModManager +import app.gamenative.ui.component.dialog.CommunityConfigsDialog import app.gamenative.ui.component.dialog.ContainerConfigDialog +import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.NexusModsDialog import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.util.ContainerConfigTransfer import app.gamenative.ui.util.SnackbarManager -import app.gamenative.utils.DiagnosticsLog -import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.utils.BestConfigService import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.DiagnosticsLog import app.gamenative.utils.GameCompatibilityCache import app.gamenative.utils.GameCompatibilityService import app.gamenative.utils.ManifestInstaller import app.gamenative.utils.createPinnedShortcut -import kotlinx.coroutines.CancellationException import com.winlator.container.ContainerData import com.winlator.core.GPUInformation import java.io.File import kotlin.text.Charsets +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -73,68 +80,74 @@ internal suspend fun installMissingComponentsForConfig( gameId: Int, configJson: kotlinx.serialization.json.JsonObject, matchType: String, - uiScope: CoroutineScope, matchedGpu: String = "", + preserveConfigValues: Boolean = false, ): Boolean { val missingRequests = BestConfigService.resolveMissingManifestInstallRequests( - context, - configJson, - matchType, - matchedGpu, + context = context, + configJson = configJson, + matchType = matchType, + matchedGpu = matchedGpu, + preserveConfigValues = preserveConfigValues, ) if (missingRequests.isEmpty()) return true + val parentContext = currentCoroutineContext() + val progressJob = SupervisorJob(parentContext[Job]) + val progressScope = CoroutineScope(parentContext + progressJob) - uiScope.launch(Dispatchers.Main.immediate) { - BaseAppScreen.showKnownConfigInstallState( - gameId, - KnownConfigInstallState( - visible = true, - progress = -1f, - label = missingRequests.first().entry.name, - ), - ) - } - - for (request in missingRequests) { - val label = request.entry.id - uiScope.launch(Dispatchers.Main.immediate) { + try { + withContext(Dispatchers.Main.immediate) { BaseAppScreen.showKnownConfigInstallState( gameId, KnownConfigInstallState( visible = true, progress = -1f, - label = label, + label = missingRequests.first().entry.name, ), ) } - val result = ManifestInstaller.installManifestEntry( - context = context, - entry = request.entry, - isDriver = request.isDriver, - contentType = request.contentType, - onProgress = { progress -> - val clamped = progress.coerceIn(0f, 1f) - uiScope.launch(Dispatchers.Main.immediate) { - BaseAppScreen.showKnownConfigInstallState( - gameId, - KnownConfigInstallState( - visible = true, - progress = clamped, - label = label, - ), - ) - } - }, - ) - SnackbarManager.show(result.message) - if (!result.success) { - uiScope.launch(Dispatchers.Main.immediate) { BaseAppScreen.hideKnownConfigInstallState(gameId) } - return false + + for (request in missingRequests) { + val label = request.entry.id + withContext(Dispatchers.Main.immediate) { + BaseAppScreen.showKnownConfigInstallState( + gameId, + KnownConfigInstallState( + visible = true, + progress = -1f, + label = label, + ), + ) + } + val result = ManifestInstaller.installManifestEntry( + context = context, + entry = request.entry, + isDriver = request.isDriver, + contentType = request.contentType, + onProgress = { progress -> + val clamped = progress.coerceIn(0f, 1f) + progressScope.launch(Dispatchers.Main.immediate) { + BaseAppScreen.showKnownConfigInstallState( + gameId, + KnownConfigInstallState( + visible = true, + progress = clamped, + label = label, + ), + ) + } + }, + ) + SnackbarManager.show(result.message) + if (!result.success) return false + } + return true + } finally { + progressJob.cancel() + withContext(NonCancellable + Dispatchers.Main.immediate) { + BaseAppScreen.hideKnownConfigInstallState(gameId) } } - - uiScope.launch(Dispatchers.Main.immediate) { BaseAppScreen.hideKnownConfigInstallState(gameId) } - return true } abstract class BaseAppScreen { @@ -145,6 +158,7 @@ abstract class BaseAppScreen { private val exportSavesRequests = mutableStateMapOf() private val importSavesRequests = mutableStateMapOf() private val manageModsRequests = mutableStateMapOf() + private val communityConfigRequests = mutableStateMapOf() private val knownConfigInstallStates = mutableStateMapOf() fun showInstallDialog(appId: String, state: app.gamenative.ui.component.dialog.state.MessageDialogState) { @@ -219,6 +233,18 @@ abstract class BaseAppScreen { return manageModsRequests[appId] == true } + fun requestCommunityConfigs(appId: String) { + communityConfigRequests[appId] = true + } + + fun clearCommunityConfigsRequest(appId: String) { + communityConfigRequests.remove(appId) + } + + fun shouldBrowseCommunityConfigs(appId: String): Boolean { + return communityConfigRequests[appId] == true + } + // missing components that prevent config from being applied data class MissingComponentsState( val components: List, @@ -546,6 +572,17 @@ abstract class BaseAppScreen { ) } + @Composable + protected open fun getBrowseCommunityConfigsOption( + context: Context, + libraryItem: LibraryItem, + ): AppMenuOption? { + return AppMenuOption( + optionType = AppOptionMenuType.BrowseCommunityConfigs, + onClick = { requestCommunityConfigs(libraryItem.appId) }, + ) + } + /** * Get export-config menu option. Subclasses can override to customize behavior * or disable export-config entirely by returning null. @@ -631,6 +668,7 @@ abstract class BaseAppScreen { val configOptions = if (supportsContainerConfig()) { listOfNotNull( getUseKnownConfigOption(context, libraryItem), + getBrowseCommunityConfigsOption(context, libraryItem), getExportConfigOption(context, libraryItem), getImportConfigOption(context, libraryItem), ) @@ -827,7 +865,6 @@ abstract class BaseAppScreen { gameId = gameId, configJson = bestConfig.bestConfig, matchType = bestConfig.matchType, - uiScope = uiScope, matchedGpu = bestConfig.matchedGpu, ) if (!installsOk) return @@ -836,7 +873,7 @@ abstract class BaseAppScreen { val configJson = bestConfig.bestConfig val matchType = bestConfig.matchType - val parsedConfig = BestConfigService.parseConfigToContainerData( + val parsedResult = BestConfigService.parseConfigResult( context = context, configJson = configJson, matchType = matchType, @@ -844,7 +881,8 @@ abstract class BaseAppScreen { storeMatch = bestConfig.matchedStore.equals(libraryItem.gameSource.name, ignoreCase = true), matchedGpu = bestConfig.matchedGpu, ) - val missingComponents = BestConfigService.consumeLastMissingComponents() + val parsedConfig = parsedResult.config + val missingComponents = parsedResult.missingComponents if (missingComponents.isNotEmpty()) { showMissingComponentsDialog(appId, missingComponents) { @@ -857,7 +895,7 @@ abstract class BaseAppScreen { forceApply = true, matchedGpu = bestConfig.matchedGpu, ) - if (forced != null && forced.isNotEmpty()) { + if (!forced.isNullOrEmpty()) { val c = ContainerUtils.getOrCreateContainer(context, appId) val cd = ContainerUtils.toContainerData(c) val updated = ContainerUtils.applyBestConfigMapToContainerData(cd, forced) @@ -872,7 +910,7 @@ abstract class BaseAppScreen { } } } - } else if (parsedConfig != null && parsedConfig.isNotEmpty()) { + } else if (parsedConfig.isNotEmpty()) { val container = ContainerUtils.getOrCreateContainer(context, appId) val currentData = ContainerUtils.toContainerData(container) val updatedData = ContainerUtils.applyBestConfigMapToContainerData( @@ -900,6 +938,122 @@ abstract class BaseAppScreen { } } + /** Applies a selected community config using the existing validation and dependency installers. */ + protected open suspend fun applyCommunityConfigForLibraryItem( + context: Context, + libraryItem: LibraryItem, + configJson: kotlinx.serialization.json.JsonObject, + matchType: String, + matchedGpu: String, + applyLaunchArguments: Boolean, + applyEnvironmentVariables: Boolean, + ): Boolean { + val appId = libraryItem.appId + val gameId = libraryItem.gameId + val uiScope = CoroutineScope(Dispatchers.Main.immediate) + val safeConfig = prepareCommunityConfigForApply( + config = configJson, + applyLaunchArguments = applyLaunchArguments, + applyEnvironmentVariables = applyEnvironmentVariables, + ) + + if (!isValidCommunityConfig(safeConfig)) { + SnackbarManager.show(context.getString(R.string.best_config_known_config_invalid)) + return false + } + + return try { + val installsOk = installMissingComponentsForConfig( + context = context, + gameId = gameId, + configJson = safeConfig, + matchType = matchType, + matchedGpu = matchedGpu, + preserveConfigValues = true, + ) + if (!installsOk) return false + + val parsedResult = BestConfigService.parseConfigResult( + context = context, + configJson = safeConfig, + matchType = matchType, + applyKnownConfig = true, + storeMatch = false, + matchedGpu = matchedGpu, + preserveConfigValues = true, + ) + val parsedConfig = parsedResult.config + val missingComponents = parsedResult.missingComponents + + if (missingComponents.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { + showMissingComponentsDialog(appId, missingComponents) { + uiScope.launch(Dispatchers.IO) { + try { + val forced = BestConfigService.parseConfigToContainerData( + context = context, + configJson = safeConfig, + matchType = matchType, + applyKnownConfig = true, + storeMatch = false, + forceApply = true, + matchedGpu = matchedGpu, + preserveConfigValues = true, + ) + if (!forced.isNullOrEmpty()) { + val container = ContainerUtils.getOrCreateContainer(context, appId) + val currentData = ContainerUtils.toContainerData(container) + val updatedData = ContainerUtils.applyBestConfigMapToContainerData(currentData, forced) + ContainerUtils.applyToContainer(context, container, updatedData) + SnackbarManager.show(context.getString(R.string.best_config_applied_with_defaults)) + } else { + SnackbarManager.show(context.getString(R.string.best_config_known_config_invalid)) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.w(error, "Failed to force-apply community config: ${error.message}") + SnackbarManager.show( + context.getString( + R.string.best_config_apply_failed, + error.message ?: "Unknown error", + ), + ) + } + } + } + } + false + } else if (parsedConfig.isNotEmpty()) { + withContext(Dispatchers.IO) { + val container = ContainerUtils.getOrCreateContainer(context, appId) + val currentData = ContainerUtils.toContainerData(container) + val updatedData = ContainerUtils.applyBestConfigMapToContainerData(currentData, parsedConfig) + ContainerUtils.applyToContainer(context, container, updatedData) + } + SnackbarManager.show(context.getString(R.string.best_config_applied_successfully)) + true + } else { + SnackbarManager.show(context.getString(R.string.best_config_known_config_invalid)) + false + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + withContext(Dispatchers.Main.immediate) { + hideKnownConfigInstallState(gameId) + } + Timber.w(error, "Failed to apply community config for $appId: ${error.message}") + SnackbarManager.show( + context.getString( + R.string.best_config_apply_failed, + error.message ?: "Unknown error", + ), + ) + false + } + } + /** * Common reset confirmation dialog for all game sources. */ @@ -1076,6 +1230,9 @@ abstract class BaseAppScreen { var containerData by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(ContainerData()) } + var communityContainerData by remember(appId) { + mutableStateOf(null) + } val onEditContainer: () -> Unit = { containerData = loadContainerData(context, libraryItem) @@ -1289,6 +1446,17 @@ abstract class BaseAppScreen { } } + var communityConfigsRequested by remember(appId) { + mutableStateOf(shouldBrowseCommunityConfigs(appId)) + } + + LaunchedEffect(appId) { + snapshotFlow { shouldBrowseCommunityConfigs(appId) } + .collect { shouldRequest -> + communityConfigsRequested = shouldRequest + } + } + val optionsMenu = getOptionsMenu(context, libraryItem, onEditContainer, onBack, onClickPlay, onTestGraphics, onPlayWithDiagnostics, exportFrontendLauncher) // Get download info based on game source for progress tracking @@ -1359,7 +1527,7 @@ abstract class BaseAppScreen { }, onBack = onBack, optionsMenu = optionsMenu, - dialogOpen = showConfigDialog || manageModsRequested, + dialogOpen = showConfigDialog || communityConfigsRequested || manageModsRequested, ) if (showReadiness && launchActivity != null) { @@ -1388,6 +1556,50 @@ abstract class BaseAppScreen { ) } + LaunchedEffect(appId, communityConfigsRequested) { + communityContainerData = if (communityConfigsRequested) { + withContext(Dispatchers.IO) { + loadContainerData(context, libraryItem) + } + } else { + null + } + } + + if (communityConfigsRequested) { + val currentContainerData = communityContainerData + if (currentContainerData == null) { + LoadingDialog( + visible = true, + onDismissRequest = { clearCommunityConfigsRequest(appId) }, + progress = -1f, + message = stringResource(R.string.working), + ) + } else { + CommunityConfigsDialog( + visible = true, + gameName = displayInfo.name, + currentLaunchArguments = currentContainerData.execArgs, + currentEnvironmentVariables = currentContainerData.envVars, + onDismissRequest = { clearCommunityConfigsRequest(appId) }, + onApply = { run, matchType, options -> + clearCommunityConfigsRequest(appId) + uiScope.launch(Dispatchers.IO) { + applyCommunityConfigForLibraryItem( + context = context, + libraryItem = libraryItem, + configJson = run.config, + matchType = matchType, + matchedGpu = run.device.gpu, + applyLaunchArguments = options.applyLaunchArguments, + applyEnvironmentVariables = options.applyEnvironmentVariables, + ) + } + }, + ) + } + } + if (manageModsRequested) { NexusModsDialog( visible = true, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt index 968c3f6dfe..977b77c8b4 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt @@ -431,8 +431,8 @@ class CustomGameAppScreen : BaseAppScreen() { } /** - * For Custom games, only show Export/Import config in the Container section. - * We intentionally omit the generic "Use known config" here. + * Custom games omit the generic "Use known config" action but retain the + * community browser and config transfer actions. */ @Composable override fun getConfigMenuOptions( @@ -440,6 +440,7 @@ class CustomGameAppScreen : BaseAppScreen() { libraryItem: LibraryItem, ): List { return listOfNotNull( + getBrowseCommunityConfigsOption(context, libraryItem), getExportConfigOption(context, libraryItem), getImportConfigOption(context, libraryItem), ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 461238a961..233650aaeb 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SdStorage import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share @@ -330,6 +331,7 @@ private fun getIconForOption(type: AppOptionMenuType): ImageVector { AppOptionMenuType.SubmitFeedback -> Icons.Default.Feedback AppOptionMenuType.ResetDrm -> Icons.Default.Key AppOptionMenuType.UseKnownConfig -> Icons.Default.Build + AppOptionMenuType.BrowseCommunityConfigs -> Icons.Default.Search AppOptionMenuType.Uninstall -> Icons.Default.Delete AppOptionMenuType.VerifyFiles -> Icons.Default.VerifiedUser AppOptionMenuType.Update -> Icons.Default.Update @@ -383,6 +385,7 @@ private fun groupOptions(options: List): Map() - // unavailable components from last config validation - private var lastMissingComponents: List = emptyList() - - fun consumeLastMissingComponents(): List { - val result = lastMissingComponents - lastMissingComponents = emptyList() - return result - } /** * Data class for API response. */ @@ -61,7 +51,7 @@ object BestConfigService { */ data class CompatibilityMessage( val text: String, - val color: Color + val color: Color, ) data class ManifestInstallRequest( @@ -70,6 +60,11 @@ object BestConfigService { val isDriver: Boolean = false, ) + data class ParsedConfigResult( + val config: Map, + val missingComponents: List = emptyList(), + ) + /** * Fetches best configuration for a game. * Returns cached response if available, otherwise makes API call. @@ -179,7 +174,8 @@ object BestConfigService { /** * Filters config JSON based on match type. - * For fallback_match, excludes containerVariant, graphicsDriver, dxwrapper, and dxwrapperConfig. + * For fallback_match, excludes GPU-specific driver and wrapper settings. The container variant + * remains because it determines which runtime and dependency variants the config requires. */ fun filterConfigByMatchType(config: JsonObject, matchType: String, storeMatch: Boolean = true): JsonObject { val filtered = config.toMutableMap() @@ -194,7 +190,7 @@ object BestConfigService { } if (matchType == "fallback_match") { - // Exclude containerVariant, graphicsDriver, dxwrapper, dxwrapperConfig + // Exclude GPU-specific driver and wrapper settings. filtered.remove("graphicsDriver") filtered.remove("graphicsDriverVersion") filtered.remove("graphicsDriverConfig") @@ -242,6 +238,16 @@ object BestConfigService { filteredJson.put("graphicsDriverVersion", "Turnip Adreno Driver T26 (@Mr_Purple_666)") } + if (GPUInformation.isAdreno8Elite(context) && + !GPUInformation.isAdreno8EliteGen5(context) && + !matched.matches(Regex(".*adreno.*\\b83[0-9]\\b.*")) + ) { + val kvs = KeyValueSet(filteredJson.optString("graphicsDriverConfig", "")) + kvs.put("version", ContainerUtils.WRAPPER_ADRENO_8ELITE) + filteredJson.put("graphicsDriverConfig", kvs.toString()) + filteredJson.put("graphicsDriverVersion", ContainerUtils.WRAPPER_ADRENO_8ELITE) + } + if (GPUInformation.isAdrenoA12(context) && !matched.matches(Regex(".*adreno.*\\ba12\\b.*"))) { val kvs = KeyValueSet(filteredJson.optString("graphicsDriverConfig", "")) kvs.put("version", ContainerUtils.WRAPPER_ADRENO_A12) @@ -262,6 +268,24 @@ object BestConfigService { return filteredJson } + private fun prepareConfigForApplication( + context: Context, + configJson: JsonObject, + matchType: String, + storeMatch: Boolean = true, + matchedGpu: String = "", + preserveConfigValues: Boolean = false, + ): JSONObject { + val effectiveMatchType = if (preserveConfigValues) "exact_gpu_match" else matchType + val filteredConfig = filterConfigByMatchType(configJson, effectiveMatchType, storeMatch) + val filteredJson = JSONObject(filteredConfig.toString()) + return if (preserveConfigValues) { + filteredJson + } else { + applyGpuFamilyOverrides(context, filteredJson, matchedGpu) + } + } + /** * Validates component versions in the filtered JSON. * Returns list of human-readable descriptions of missing/unavailable components. @@ -488,10 +512,16 @@ object BestConfigService { configJson: JsonObject, matchType: String, matchedGpu: String = "", + preserveConfigValues: Boolean = false, ): List { val updatedConfigJson = Json.parseToJsonElement(configJson.toString()).jsonObject - val filteredConfig = filterConfigByMatchType(updatedConfigJson, matchType) - val filteredJson = applyGpuFamilyOverrides(context, JSONObject(filteredConfig.toString()), matchedGpu) + val filteredJson = prepareConfigForApplication( + context = context, + configJson = updatedConfigJson, + matchType = matchType, + matchedGpu = matchedGpu, + preserveConfigValues = preserveConfigValues, + ) val installed = ManifestComponentHelper.loadInstalledContentLists(context) val manifest = ManifestRepository.loadManifest(context) val installedContent = installed.installed @@ -742,6 +772,7 @@ object BestConfigService { * First parses values (using PrefManager defaults for validation), then validates component versions. * Returns map with only fields present in config (no defaults), or empty map if validation fails. * When forceApply is true, missing components are replaced with defaults instead of rejecting. + * When preserveConfigValues is true, match filtering and device-specific substitutions are skipped. */ suspend fun parseConfigToContainerData( context: Context, @@ -751,7 +782,28 @@ object BestConfigService { storeMatch: Boolean = true, forceApply: Boolean = false, matchedGpu: String = "", - ): Map? { + preserveConfigValues: Boolean = false, + ): Map? = parseConfigResult( + context = context, + configJson = configJson, + matchType = matchType, + applyKnownConfig = applyKnownConfig, + storeMatch = storeMatch, + forceApply = forceApply, + matchedGpu = matchedGpu, + preserveConfigValues = preserveConfigValues, + ).config + + suspend fun parseConfigResult( + context: Context, + configJson: JsonObject, + matchType: String, + applyKnownConfig: Boolean, + storeMatch: Boolean = true, + forceApply: Boolean = false, + matchedGpu: String = "", + preserveConfigValues: Boolean = false, + ): ParsedConfigResult { try { val originalJson = JSONObject(configJson.toString()) @@ -763,13 +815,13 @@ object BestConfigService { if (originalJson.has("useLegacyDRM") && !originalJson.isNull("useLegacyDRM")) { resultMap["useLegacyDRM"] = originalJson.optBoolean("useLegacyDRM", PrefManager.useLegacyDRM) } - return resultMap + return ParsedConfigResult(resultMap) } else { if (!originalJson.has("containerVariant") || originalJson.isNull("containerVariant")) { Timber.tag("BestConfigService").w("containerVariant is missing or null in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } val containerVariant = originalJson.optString("containerVariant", "") @@ -778,7 +830,7 @@ object BestConfigService { // server best-config responses nor JSON imports can switch a container to glibc. if (BuildConfig.MODERN_ANDROID && containerVariant.equals(Container.GLIBC, ignoreCase = true)) { Timber.tag("BestConfigService").w("Rejecting glibc containerVariant on modern flavor") - return mapOf() + return ParsedConfigResult(emptyMap()) } if (!originalJson.has("wineVersion") || originalJson.isNull("wineVersion")) { @@ -787,16 +839,16 @@ object BestConfigService { } else { Timber.tag("BestConfigService").w("wineVersion is missing or null in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } } if (!originalJson.has("dxwrapper") || originalJson.isNull("dxwrapper")) { Timber.tag("BestConfigService").w("dxwrapper is missing or null in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } if (!originalJson.has("dxwrapperConfig") || originalJson.isNull("dxwrapperConfig")) { Timber.tag("BestConfigService").w("dxwrapperConfig is missing or null in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } // Also check they're not empty strings @@ -806,35 +858,41 @@ object BestConfigService { if (containerVariant.isEmpty()) { Timber.tag("BestConfigService").w("containerVariant is empty in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } if (wineVersion.isEmpty()) { Timber.tag("BestConfigService").w("wineVersion is empty in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } if (dxwrapper.isEmpty()) { Timber.tag("BestConfigService").w("dxwrapper is empty in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } if (dxwrapperConfig.isEmpty()) { Timber.tag("BestConfigService").w("dxwrapperConfig is empty in original config, returning empty map") - return mapOf() + return ParsedConfigResult(emptyMap()) } - // Step 1: Filter config based on match type, then apply GPU-family overrides + // Step 1: Prepare the config using either device-adapted or value-preserving behavior val updatedConfigJson = Json.parseToJsonElement(originalJson.toString()).jsonObject - val filteredConfig = filterConfigByMatchType(updatedConfigJson, matchType, storeMatch) - val filteredJson = applyGpuFamilyOverrides(context, JSONObject(filteredConfig.toString()), matchedGpu) + val filteredJson = prepareConfigForApplication( + context = context, + configJson = updatedConfigJson, + matchType = matchType, + storeMatch = storeMatch, + matchedGpu = matchedGpu, + preserveConfigValues = preserveConfigValues, + ) // Step 2: check for unavailable component versions - lastMissingComponents = validateComponentVersions(context, filteredJson) - if (lastMissingComponents.isNotEmpty()) { + val missingComponents = validateComponentVersions(context, filteredJson) + if (missingComponents.isNotEmpty()) { if (!forceApply) { - Timber.tag("BestConfigService").w("Config rejected: missing components: ${lastMissingComponents.joinToString(", ")}") - return mapOf() + Timber.tag("BestConfigService").w("Config rejected: missing components: ${missingComponents.joinToString(", ")}") + return ParsedConfigResult(emptyMap(), missingComponents) } - Timber.tag("BestConfigService").w("Force-applying config, replacing missing components with defaults: ${lastMissingComponents.joinToString(", ")}") - replaceWithDefaults(filteredJson, lastMissingComponents) + Timber.tag("BestConfigService").w("Force-applying config, replacing missing components with defaults: ${missingComponents.joinToString(", ")}") + replaceWithDefaults(filteredJson, missingComponents) } // Step 3: Build map with only fields present in filteredJson (not defaults) @@ -861,7 +919,12 @@ object BestConfigService { resultMap["execArgs"] = filteredJson.optString("execArgs", "") } if (filteredJson.has("startupSelection") && !filteredJson.isNull("startupSelection")) { - resultMap["startupSelection"] = filteredJson.optInt("startupSelection", PrefManager.startupSelection).toByte() + val startupSelection = filteredJson.optInt("startupSelection", PrefManager.startupSelection) + resultMap["startupSelection"] = if (preserveConfigValues) { + startupSelection + } else { + startupSelection.toByte() + } } if (filteredJson.has("box64Version") && !filteredJson.isNull("box64Version")) { resultMap["box64Version"] = filteredJson.optString("box64Version", "") @@ -922,11 +985,11 @@ object BestConfigService { resultMap["videoMemorySize"] = filteredJson.optString("videoMemorySize", PrefManager.videoMemorySize) } - return resultMap + return ParsedConfigResult(resultMap, missingComponents) } } catch (e: Exception) { Timber.tag("BestConfigService").e(e, "Failed to parse config to ContainerData: ${e.message}") - return mapOf() + return ParsedConfigResult(emptyMap()) } } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 8b762e6824..718cea0cee 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1027,6 +1027,64 @@ Anvend alligevel Konfiguration anvendt, manglende komponenter erstattet med standardværdier Kunne ikke anvende konfiguration: %s + Fællesskabskonfigurationer + Luk fællesskabskonfigurationer + Opdater fællesskabskonfigurationer + Prøv igen + Ukendt fejl + Ukendt + Fællesskabskonfigurationerne kunne ikke indlæses: %1$s + Der blev ikke fundet en kompatibilitetspost for %1$s + GPU er ikke tilgængelig + + %1$d konfiguration + %1$d konfigurationer + + Bedst bedømte + Nyeste + Samme enhed + Samme GPU + Kompatible + Indlæs flere + Der blev ikke fundet konfigurationer til denne enhed. + Der blev ikke fundet konfigurationer til denne GPU. + Der blev ikke fundet konfigurationer til kompatible GPU’er. + Vis denne GPU + Vis kompatible GPU’er + %1$d/5 + %1$s FPS + Konfigurationsdetaljer + Dette resultat bruger en anden GPU. GPU-specifikke driver- og wrapperindstillinger anvendes ikke. + Testkørsel + Bedømmelse + Gennemsnitlig FPS + Enhed + GPU + Android + SoC + GameNative-version + Indsendt + Sessionslængde + Spilbutik + Brugerdefineret spil + Indstillinger + Yderligere startindstillinger + Anvend startargumenter + Anvend miljøvariabler + Erstatter den aktuelle værdi + Afhængigheder + Afhængighederne kontrolleres, når konfigurationen anvendes. + Der kræves i øjeblikket ingen downloads fra manifestet. + Disse komponenter downloades automatisk:\n%1$s + Mærker + Noter + Anvend konfiguration + Container + Wine eller Proton + Emulator + DirectX-wrapper + Box64-forudindstilling + Grafikdriver Importér konfiguration Eksportér konfiguration @@ -1343,6 +1401,7 @@ Send feedback Nulstil DRM Brug kendt konfiguration + Gennemse fællesskabskonfigurationer Anbefalet diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3c587a1cc8..9060d39140 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1146,6 +1146,64 @@ Trotzdem anwenden Konfiguration angewendet, fehlende Komponenten durch Standardwerte ersetzt Konfiguration konnte nicht übernommen werden: %s + Community-Konfigurationen + Community-Konfigurationen schließen + Community-Konfigurationen aktualisieren + Erneut versuchen + Unbekannter Fehler + Unbekannt + Community-Konfigurationen konnten nicht geladen werden: %1$s + Für %1$s wurde kein Kompatibilitätseintrag gefunden + GPU nicht verfügbar + + %1$d Konfiguration + %1$d Konfigurationen + + Höchste Bewertung + Neueste + Dasselbe Gerät + Gleiche GPU + Kompatibel + Weitere laden + Für dieses Gerät wurden keine Konfigurationen gefunden. + Für diese GPU wurden keine Konfigurationen gefunden. + Für kompatible GPUs wurden keine Konfigurationen gefunden. + Diese GPU anzeigen + Kompatible GPUs anzeigen + %1$d/5 + %1$s FPS + Konfigurationsdetails + Dieses Ergebnis verwendet eine andere GPU. GPU-spezifische Treiber- und Wrapper-Einstellungen werden nicht angewendet. + Testlauf + Bewertung + Durchschnittliche FPS + Gerät + GPU + Android + SoC + GameNative-Version + Eingereicht + Sitzungsdauer + Spiele-Shop + Benutzerdefiniertes Spiel + Einstellungen + Zusätzliche Starteinstellungen + Startargumente anwenden + Umgebungsvariablen anwenden + Ersetzt den aktuellen Wert + Abhängigkeiten + Die Abhängigkeiten werden beim Anwenden der Konfiguration überprüft. + Derzeit sind keine Manifest-Downloads erforderlich. + Diese Komponenten werden automatisch heruntergeladen:\n%1$s + Tags + Notizen + Konfiguration anwenden + Container + Wine oder Proton + Emulator + DirectX-Wrapper + Box64-Voreinstellung + Grafiktreiber Konfiguration importieren Konfiguration exportieren App-Typ @@ -1413,6 +1471,7 @@ Feedback senden DRM zurücksetzen Bekannte Konfiguration verwenden + Community-Konfigurationen durchsuchen Empfohlen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index eb7501ba32..87da956b45 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1212,6 +1212,64 @@ Aplicar de todos modos Configuración aplicada, componentes faltantes reemplazados por valores predeterminados Error al aplicar la configuración: %s. + Configuraciones de la comunidad + Cerrar configuraciones de la comunidad + Actualizar configuraciones de la comunidad + Reintentar + Error desconocido + Desconocido + No se pudieron cargar las configuraciones de la comunidad: %1$s + No se encontró ninguna entrada de compatibilidad para %1$s + GPU no disponible + + %1$d configuración + %1$d configuraciones + + Mejor valoradas + Más recientes + Mismo dispositivo + Misma GPU + Compatibles + Cargar más + No se encontraron configuraciones para este dispositivo. + No se encontraron configuraciones para esta GPU. + No se encontraron configuraciones para GPU compatibles. + Mostrar esta GPU + Mostrar GPU compatibles + %1$d/5 + %1$s FPS + Detalles de la configuración + Este resultado utiliza otra GPU. No se aplicarán los ajustes del controlador y del wrapper específicos de la GPU. + Ejecución de prueba + Valoración + FPS medios + Dispositivo + GPU + Android + SoC + Versión de GameNative + Enviada + Duración de la sesión + Tienda del juego + Juego personalizado + Ajustes + Ajustes de inicio adicionales + Aplicar argumentos de inicio + Aplicar variables de entorno + Reemplaza el valor actual + Dependencias + Las dependencias se comprobarán al aplicar la configuración. + No se requiere actualmente ninguna descarga del manifiesto. + Estos componentes se descargarán automáticamente:\n%1$s + Etiquetas + Notas + Aplicar configuración + Contenedor + Wine o Proton + Emulador + Wrapper de DirectX + Preajuste de Box64 + Controlador gráfico Importar configuración Exportar configuración Tipo de aplicación @@ -1471,6 +1529,7 @@ Enviar comentarios Restablecer DRM Usar configuración conocida + Explorar configuraciones de la comunidad Recomendado diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 946d026b97..396d059c87 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1204,6 +1204,64 @@ Appliquer quand même Configuration appliquée, composants manquants remplacés par les valeurs par défaut Échec de l\'application de la configuration : %s + Configurations de la communauté + Fermer les configurations de la communauté + Actualiser les configurations de la communauté + Réessayer + Erreur inconnue + Inconnu + Impossible de charger les configurations de la communauté : %1$s + Aucune entrée de compatibilité trouvée pour %1$s + GPU indisponible + + %1$d configuration + %1$d configurations + + Les mieux notées + Les plus récentes + Même appareil + Même GPU + Compatibles + Charger plus + Aucune configuration trouvée pour cet appareil. + Aucune configuration trouvée pour ce GPU. + Aucune configuration trouvée pour les GPU compatibles. + Afficher ce GPU + Afficher les GPU compatibles + %1$d/5 + %1$s FPS + Détails de la configuration + Ce résultat utilise un autre GPU. Les paramètres du pilote et du wrapper propres au GPU ne seront pas appliqués. + Exécution de test + Note + FPS moyens + Appareil + GPU + Android + SoC + Version de GameNative + Envoyée + Durée de la session + Boutique du jeu + Jeu personnalisé + Paramètres + Paramètres de lancement supplémentaires + Appliquer les arguments de lancement + Appliquer les variables d’environnement + Remplace la valeur actuelle + Dépendances + Les dépendances seront vérifiées lors de l’application de la configuration. + Aucun téléchargement depuis le manifeste n’est actuellement requis. + Ces composants seront téléchargés automatiquement :\n%1$s + Étiquettes + Notes + Appliquer la configuration + Conteneur + Wine ou Proton + Émulateur + Wrapper DirectX + Préréglage Box64 + Pilote graphique Importer la configuration Exporter la configuration Type d\'app @@ -1473,6 +1531,7 @@ Envoyer un commentaire Réinitialiser le DRM Utiliser la configuration connue + Parcourir les configurations de la communauté Recommandé diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e23ab22a92..dcce534bfa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1195,6 +1195,64 @@ Applica comunque Configurazione applicata, componenti mancanti sostituiti con valori predefiniti Impossibile applicare config: %s + Configurazioni della community + Chiudi configurazioni della community + Aggiorna configurazioni della community + Riprova + Errore sconosciuto + Sconosciuto + Impossibile caricare le configurazioni della community: %1$s + Nessuna voce di compatibilità trovata per %1$s + GPU non disponibile + + %1$d configurazione + %1$d configurazioni + + Con valutazione più alta + Più recenti + Stesso dispositivo + Stessa GPU + Compatibili + Carica altro + Nessuna configurazione trovata per questo dispositivo. + Nessuna configurazione trovata per questa GPU. + Nessuna configurazione trovata per GPU compatibili. + Mostra questa GPU + Mostra GPU compatibili + %1$d/5 + %1$s FPS + Dettagli della configurazione + Questo risultato usa un’altra GPU. Le impostazioni del driver e del wrapper specifiche per la GPU non verranno applicate. + Esecuzione di prova + Valutazione + FPS medi + Dispositivo + GPU + Android + SoC + Versione di GameNative + Inviata + Durata sessione + Negozio del gioco + Gioco personalizzato + Impostazioni + Impostazioni di avvio aggiuntive + Applica argomenti di avvio + Applica variabili d\'ambiente + Sostituisce il valore corrente + Dipendenze + Le dipendenze verranno controllate quando viene applicata la configurazione. + Al momento non è richiesto alcun download dal manifesto. + Questi componenti verranno scaricati automaticamente:\n%1$s + Tag + Note + Applica configurazione + Container + Wine o Proton + Emulatore + Wrapper DirectX + Preimpostazione Box64 + Driver grafico Importa configurazione Esporta configurazione Tipo App @@ -1464,6 +1522,7 @@ Invia feedback Reimposta DRM Usa config nota + Sfoglia configurazioni della community Consigliato diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5fb2a0b4cb..2ed13fa188 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1232,6 +1232,63 @@ このゲームに最適な設定はありません 構成の取得に失敗しました。ネットワーク接続を確認してください。 構成の適用に失敗しました: %s + コミュニティ設定 + コミュニティ設定を閉じる + コミュニティ設定を更新 + 再試行 + 不明なエラー + 不明 + コミュニティ設定を読み込めませんでした: %1$s + %1$s の互換性情報が見つかりませんでした + GPU を利用できません + + %1$d 件の設定 + + 評価順 + 新着順 + 同じデバイス + 同じ GPU + 互換 + さらに読み込む + このデバイス向けの設定は見つかりませんでした。 + この GPU 向けの設定は見つかりませんでした。 + 互換 GPU の設定は見つかりませんでした。 + この GPU を表示 + 互換 GPU を表示 + %1$d/5 + %1$s FPS + 設定の詳細 + この結果は別の GPU を使用しています。GPU 固有のドライバーとラッパーの設定は適用されません。 + テスト実行 + 評価 + 平均 FPS + デバイス + GPU + Android + SoC + GameNative バージョン + 送信日時 + セッション時間 + ゲームストア + カスタムゲーム + 設定 + 追加の起動設定 + 起動引数を適用 + 環境変数を適用 + 現在の値を置き換えます + 依存関係 + 設定を適用するときに依存関係を確認します。 + 現在、マニフェストからのダウンロードは必要ありません。 + 次のコンポーネントは自動的にダウンロードされます:\n%1$s + タグ + メモ + 設定を適用 + コンテナ + Wine または Proton + エミュレーター + DirectX ラッパー + Box64 プリセット + グラフィックスドライバー 設定が適用されていません 次の必須コンポーネントは使用できません:\n\n%1$s とにかく応募してください @@ -1496,6 +1553,7 @@ フィードバックを送信する DRMをリセットする 既知の構成を使用する + コミュニティ設定を参照 インポート保存 保存のエクスポート ファイルの検証 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ecb28b7896..b65cde490a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1199,6 +1199,63 @@ 그래도 적용 구성이 적용되었습니다. 누락된 구성 요소가 기본값으로 대체되었습니다 설정 적용 실패: %s + 커뮤니티 구성 + 커뮤니티 구성 닫기 + 커뮤니티 구성 새로 고침 + 다시 시도 + 알 수 없는 오류 + 알 수 없음 + 커뮤니티 구성을 불러올 수 없습니다: %1$s + %1$s의 호환성 항목을 찾을 수 없습니다 + GPU를 사용할 수 없음 + + 구성 %1$d개 + + 평점 높은 순 + 최신순 + 동일한 기기 + 동일한 GPU + 호환 + 더 불러오기 + 이 기기의 구성을 찾을 수 없습니다. + 이 GPU의 구성을 찾을 수 없습니다. + 호환 GPU용 구성을 찾을 수 없습니다. + 이 GPU 표시 + 호환 GPU 표시 + %1$d/5 + %1$s FPS + 구성 세부 정보 + 이 결과는 다른 GPU를 사용합니다. GPU 전용 드라이버 및 래퍼 설정은 적용되지 않습니다. + 테스트 실행 + 평점 + 평균 FPS + 기기 + GPU + Android + SoC + GameNative 버전 + 제출일 + 세션 시간 + 게임 스토어 + 사용자 지정 게임 + 설정 + 추가 실행 설정 + 실행 인수 적용 + 환경 변수 적용 + 현재 값을 대체합니다 + 종속 항목 + 구성을 적용할 때 종속 항목을 확인합니다. + 현재 필요한 매니페스트 다운로드가 없습니다. + 다음 구성 요소가 자동으로 다운로드됩니다:\n%1$s + 태그 + 메모 + 구성 적용 + 컨테이너 + Wine 또는 Proton + 에뮬레이터 + DirectX 래퍼 + Box64 사전 설정 + 그래픽 드라이버 설정 가져오기 설정 내보내기 앱 유형 @@ -1594,6 +1651,7 @@ 피드백 제출 DRM 초기화 알려진 설정 사용 + 커뮤니티 구성 찾아보기 파일 무결성 확인 업데이트 외부 저장소로 이동 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 41555ba68a..df2c96f443 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1212,6 +1212,66 @@ Zastosuj mimo to Konfiguracja zastosowana, brakujące komponenty zastąpione wartościami domyślnymi Nie udało się zastosować konfiguracji: %s + Konfiguracje społeczności + Zamknij konfiguracje społeczności + Odśwież konfiguracje społeczności + Spróbuj ponownie + Nieznany błąd + Nieznane + Nie udało się wczytać konfiguracji społeczności: %1$s + Nie znaleziono wpisu zgodności dla %1$s + GPU niedostępne + + %1$d konfiguracja + %1$d konfiguracje + %1$d konfiguracji + %1$d konfiguracji + + Najwyżej oceniane + Najnowsze + To samo urządzenie + Ten sam układ GPU + Zgodne + Wczytaj więcej + Nie znaleziono konfiguracji dla tego urządzenia. + Nie znaleziono konfiguracji dla tego układu GPU. + Nie znaleziono konfiguracji dla zgodnych układów GPU. + Pokaż ten układ GPU + Pokaż zgodne układy GPU + %1$d/5 + %1$s FPS + Szczegóły konfiguracji + Ten wynik dotyczy innego układu GPU. Ustawienia sterownika i wrappera zależne od GPU nie zostaną zastosowane. + Uruchomienie testowe + Ocena + Średnia liczba FPS + Urządzenie + GPU + Android + SoC + Wersja GameNative + Przesłano + Czas sesji + Sklep z grą + Gra niestandardowa + Ustawienia + Dodatkowe ustawienia uruchamiania + Zastosuj argumenty uruchamiania + Zastosuj zmienne środowiskowe + Zastępuje bieżącą wartość + Zależności + Zależności zostaną sprawdzone podczas stosowania konfiguracji. + Obecnie nie są wymagane żadne pliki do pobrania z manifestu. + Te składniki zostaną pobrane automatycznie:\n%1$s + Tagi + Notatki + Zastosuj konfigurację + Kontener + Wine lub Proton + Emulator + Wrapper DirectX + Ustawienie Box64 + Sterownik graficzny Importuj konfigurację Eksportuj konfigurację Typ aplikacji @@ -1471,6 +1531,7 @@ Wyślij opinię Zresetuj DRM Użyj znanej konfiguracji + Przeglądaj konfiguracje społeczności Polecane diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 510416658d..09f10721f1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1027,6 +1027,64 @@ Aplicar mesmo assim Configuração aplicada, componentes ausentes substituídos por padrões Falha ao aplicar config: %s + Configurações da comunidade + Fechar configurações da comunidade + Atualizar configurações da comunidade + Tentar novamente + Erro desconhecido + Desconhecido + Não foi possível carregar as configurações da comunidade: %1$s + Nenhuma entrada de compatibilidade foi encontrada para %1$s + GPU indisponível + + %1$d configuração + %1$d configurações + + Mais bem avaliadas + Mais recentes + Mesmo dispositivo + Mesma GPU + Compatíveis + Carregar mais + Nenhuma configuração foi encontrada para este dispositivo. + Nenhuma configuração foi encontrada para esta GPU. + Nenhuma configuração foi encontrada para GPUs compatíveis. + Mostrar esta GPU + Mostrar GPUs compatíveis + %1$d/5 + %1$s FPS + Detalhes da configuração + Este resultado usa outra GPU. As configurações de driver e wrapper específicas da GPU não serão aplicadas. + Execução de teste + Avaliação + Média de FPS + Dispositivo + GPU + Android + SoC + Versão do GameNative + Enviado em + Duração da sessão + Loja do jogo + Jogo personalizado + Configurações + Configurações adicionais de inicialização + Aplicar argumentos de inicialização + Aplicar variáveis de ambiente + Substitui o valor atual + Dependências + As dependências serão verificadas quando a configuração for aplicada. + Nenhum download do manifesto é necessário no momento. + Estes componentes serão baixados automaticamente:\n%1$s + Tags + Observações + Aplicar configuração + Contêiner + Wine ou Proton + Emulador + Wrapper do DirectX + Predefinição do Box64 + Driver gráfico Importar configuração Exportar configuração @@ -1343,6 +1401,7 @@ Enviar feedback Redefinir DRM Usar configuração conhecida + Procurar configurações da comunidade Recomendado diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 3e95280a10..c9c2948884 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1204,6 +1204,65 @@ Aplică oricum Configurație aplicată, componentele lipsă înlocuite cu valori implicite Aplicarea configurației a eșuat: %s + Configurații ale comunității + Închide configurațiile comunității + Actualizează configurațiile comunității + Reîncearcă + Eroare necunoscută + Necunoscut + Configurațiile comunității nu au putut fi încărcate: %1$s + Nu a fost găsită nicio intrare de compatibilitate pentru %1$s + GPU indisponibil + + %1$d configurație + %1$d configurații + %1$d de configurații + + Cele mai bine evaluate + Cele mai recente + Același dispozitiv + Același GPU + Compatibile + Încarcă mai multe + Nu au fost găsite configurații pentru acest dispozitiv. + Nu au fost găsite configurații pentru acest GPU. + Nu au fost găsite configurații pentru GPU-uri compatibile. + Afișează acest GPU + Afișează GPU-uri compatibile + %1$d/5 + %1$s FPS + Detaliile configurației + Acest rezultat folosește un alt GPU. Setările driverului și wrapperului specifice GPU-ului nu vor fi aplicate. + Rulare de test + Evaluare + FPS mediu + Dispozitiv + GPU + Android + SoC + Versiunea GameNative + Trimis + Durata sesiunii + Magazinul jocului + Joc personalizat + Setări + Setări suplimentare de lansare + Aplică argumentele de lansare + Aplică variabilele de mediu + Înlocuiește valoarea curentă + Dependențe + Dependențele vor fi verificate la aplicarea configurației. + Momentan nu sunt necesare descărcări din manifest. + Aceste componente vor fi descărcate automat:\n%1$s + Etichete + Note + Aplică configurația + Container + Wine sau Proton + Emulator + Wrapper DirectX + Presetare Box64 + Driver grafic Importă configurarea Exportă configurarea Tip aplicație @@ -1474,6 +1533,7 @@ Trimite feedback Resetează DRM Folosește configurația cunoscută + Răsfoiește configurațiile comunității Recomandat diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2a0fdc2d99..3a8b25d80f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -112,6 +112,66 @@ Конфигурация применена, отсутствующие компоненты заменены значениями по умолчанию Применить всё равно Ошибка применения конфигурации: %s + Конфигурации сообщества + Закрыть конфигурации сообщества + Обновить конфигурации сообщества + Повторить + Неизвестная ошибка + Неизвестно + Не удалось загрузить конфигурации сообщества: %1$s + Для %1$s не найдена запись о совместимости + GPU недоступен + + %1$d конфигурация + %1$d конфигурации + %1$d конфигураций + %1$d конфигурации + + Высший рейтинг + Сначала новые + То же устройство + Тот же GPU + Совместимые + Загрузить ещё + Для этого устройства не найдено конфигураций. + Для этого GPU не найдено конфигураций. + Конфигурации для совместимых GPU не найдены. + Показать этот GPU + Показать совместимые GPU + %1$d/5 + %1$s FPS + Сведения о конфигурации + Этот результат получен с другим GPU. Настройки драйвера и обёртки, зависящие от GPU, применены не будут. + Тестовый запуск + Оценка + Средний FPS + Устройство + GPU + Android + SoC + Версия GameNative + Отправлено + Длительность сеанса + Магазин игры + Пользовательская игра + Настройки + Дополнительные параметры запуска + Применить аргументы запуска + Применить переменные окружения + Заменяет текущее значение + Зависимости + Зависимости будут проверены при применении конфигурации. + Сейчас загрузка компонентов из манифеста не требуется. + Эти компоненты будут загружены автоматически:\n%1$s + Теги + Примечания + Применить конфигурацию + Контейнер + Wine или Proton + Эмулятор + Обёртка DirectX + Предустановка Box64 + Графический драйвер Известная конфигурация работает на вашем GPU Известная конфигурация может работать на вашем GPU Известная конфигурация невалидна @@ -1399,6 +1459,7 @@ https://gamenative.app Отправить отзыв Сбросить DRM Использовать известную конфигурацию + Просмотреть конфигурации сообщества Рекомендуемое diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fe0c2e625a..44242312df 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1198,6 +1198,66 @@ Застосувати все одно Конфігурацію застосовано, відсутні компоненти замінено значеннями за замовчуванням Не вдалося застосувати конфігурацію: %s + Конфігурації спільноти + Закрити конфігурації спільноти + Оновити конфігурації спільноти + Повторити + Невідома помилка + Невідомо + Не вдалося завантажити конфігурації спільноти: %1$s + Для %1$s не знайдено запису про сумісність + GPU недоступний + + %1$d конфігурація + %1$d конфігурації + %1$d конфігурацій + %1$d конфігурації + + За рейтингом + Найновіші + Той самий пристрій + Той самий GPU + Сумісні + Завантажити ще + Для цього пристрою не знайдено конфігурацій. + Для цього GPU не знайдено конфігурацій. + Конфігурацій для сумісних GPU не знайдено. + Показати цей GPU + Показати сумісні GPU + %1$d/5 + %1$s FPS + Відомості про конфігурацію + Цей результат отримано з іншим GPU. Налаштування драйвера та обгортки, що залежать від GPU, не буде застосовано. + Тестовий запуск + Оцінка + Середній FPS + Пристрій + GPU + Android + SoC + Версія GameNative + Надіслано + Тривалість сеансу + Магазин гри + Користувацька гра + Налаштування + Додаткові параметри запуску + Застосувати аргументи запуску + Застосувати змінні середовища + Замінює поточне значення + Залежності + Залежності буде перевірено під час застосування конфігурації. + Зараз завантаження компонентів із маніфесту не потрібне. + Ці компоненти буде завантажено автоматично:\n%1$s + Теги + Примітки + Застосувати конфігурацію + Контейнер + Wine або Proton + Емулятор + Обгортка DirectX + Передналаштування Box64 + Графічний драйвер Імпортувати конфігурацію Експортувати конфігурацію Тип застосунку @@ -1467,6 +1527,7 @@ Надіслати відгук Скинути DRM Використати відому конфігурацію + Переглянути конфігурації спільноти Рекомендовано diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6d08a6614b..546f47c02c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1180,6 +1180,63 @@ 仍然应用 配置已应用,缺失组件已替换为默认值 应用配置失败:%s + 社区配置 + 关闭社区配置 + 刷新社区配置 + 重试 + 未知错误 + 未知 + 无法加载社区配置:%1$s + 未找到 %1$s 的兼容性条目 + GPU 不可用 + + %1$d 个配置 + + 评分最高 + 最新 + 相同设备 + 相同 GPU + 兼容 + 加载更多 + 未找到适用于此设备的配置。 + 未找到适用于此 GPU 的配置。 + 未找到适用于兼容 GPU 的配置。 + 显示此 GPU + 显示兼容 GPU + %1$d/5 + %1$s FPS + 配置详情 + 此结果使用其他 GPU。不会应用 GPU 专用的驱动程序和封装器设置。 + 测试运行 + 评分 + 平均 FPS + 设备 + GPU + Android + SoC + GameNative 版本 + 提交时间 + 会话时长 + 游戏商店 + 自定义游戏 + 设置 + 其他启动设置 + 应用启动参数 + 应用环境变量 + 替换当前值 + 依赖项 + 应用配置时将检查依赖项。 + 目前无需从清单下载任何内容。 + 将自动下载以下组件:\n%1$s + 标签 + 备注 + 应用配置 + 容器 + Wine 或 Proton + 模拟器 + DirectX 封装器 + Box64 预设 + 图形驱动程序 导入配置 导出配置 @@ -1510,6 +1567,7 @@ 重置 DRM 更新 使用已知配置 + 浏览社区配置 验证文件 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9b72d3d8e4..8a6200d72b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1182,6 +1182,63 @@ 仍然套用 設定已套用,缺少的元件已替換為預設值 套用配置失敗: %s + 社群設定 + 關閉社群設定 + 重新整理社群設定 + 重試 + 未知錯誤 + 未知 + 無法載入社群設定:%1$s + 找不到 %1$s 的相容性項目 + GPU 無法使用 + + %1$d 個設定 + + 評分最高 + 最新 + 相同裝置 + 相同 GPU + 相容 + 載入更多 + 找不到適用於此裝置的設定。 + 找不到適用於此 GPU 的設定。 + 找不到適用於相容 GPU 的設定。 + 顯示此 GPU + 顯示相容 GPU + %1$d/5 + %1$s FPS + 設定詳細資料 + 此結果使用其他 GPU。不會套用 GPU 專用的驅動程式和封裝器設定。 + 測試執行 + 評分 + 平均 FPS + 裝置 + GPU + Android + SoC + GameNative 版本 + 提交時間 + 遊戲時長 + 遊戲商店 + 自訂遊戲 + 設定 + 其他啟動設定 + 套用啟動參數 + 套用環境變數 + 取代目前的值 + 相依性 + 套用設定時將檢查相依性。 + 目前不需要從資訊清單下載任何內容。 + 將自動下載以下元件:\n%1$s + 標籤 + 備註 + 套用設定 + 容器 + Wine 或 Proton + 模擬器 + DirectX 封裝器 + Box64 預設 + 圖形驅動程式 匯入設定 匯出設定 @@ -1505,6 +1562,7 @@ 重置 DRM 更新 使用已知配置 + 瀏覽社群設定 驗證檔案完整性 電池溫度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e432178dd..86514f88be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1362,6 +1362,64 @@ The following required components are not available:\n\n%1$s Apply anyway Config applied with missing components replaced by defaults + Community configs + Close community configs + Refresh community configs + Retry + Unknown error + Unknown + Could not load community configs: %1$s + No compatibility entry was found for %1$s + GPU unavailable + + %1$d config + %1$d configs + + Highest rated + Newest + Same device + Same GPU + Compatible + Load more + No configs were found for this device. + No configs were found for this GPU. + No configs were found for compatible GPUs. + Show this GPU + Show compatible GPUs + %1$d/5 + %1$s FPS + Config details + This result uses another GPU. GPU-specific driver and wrapper settings will not be applied. + Test run + Rating + Average FPS + Device + GPU + Android + SoC + GameNative version + Submitted + Session length + Game store + Custom game + Settings + Additional launch settings + Apply launch arguments + Apply environment variables + Replaces the current value + Dependencies + Dependencies will be checked when the config is applied. + No manifest downloads are currently required. + These components will be downloaded automatically:\n%1$s + Tags + Notes + Apply config + Container + Wine or Proton + Emulator + DirectX wrapper + Box64 preset + Graphics driver Import config Export config Saves exported @@ -1624,6 +1682,7 @@ Submit feedback Reset DRM Use known config + Browse community configs Import saves Export saves Verify files diff --git a/app/src/test/java/app/gamenative/api/CommunityConfigServiceTest.kt b/app/src/test/java/app/gamenative/api/CommunityConfigServiceTest.kt new file mode 100644 index 0000000000..97f7b15ddc --- /dev/null +++ b/app/src/test/java/app/gamenative/api/CommunityConfigServiceTest.kt @@ -0,0 +1,942 @@ +package app.gamenative.api + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CommunityConfigServiceTest { + private lateinit var server: MockWebServer + private lateinit var httpClient: OkHttpClient + private lateinit var service: CommunityConfigService + + @Before + fun setUp() { + server = MockWebServer() + server.start() + httpClient = OkHttpClient() + service = CommunityConfigService( + client = httpClient, + baseUrl = server.url("/").toString().trimEnd('/'), + requestThrottle = CommunityRequestThrottle(maxRequests = 1_000), + ) + } + + @After + fun tearDown() { + httpClient.dispatcher.executorService.shutdown() + httpClient.connectionPool.evictAll() + httpClient.cache?.close() + server.shutdown() + } + + @Test + fun searchGames_encodesQueryAndParsesResults() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "games": [ + { "id": 3405, "name": "ELDEN RING" }, + { "id": 2149, "name": "ELDEN RING NIGHTREIGN" } + ] + } + """.trimIndent(), + ), + ) + + val result = service.searchGames("ELDEN RING") + + assertEquals(listOf(3405, 2149), result.map { it.id }) + assertEquals("ELDEN RING", result.first().name) + val request = server.takeRequest() + assertEquals("/api/games/search?q=ELDEN%20RING", request.path) + } + + @Test + fun fetchConfigs_parsesRunAndUsesGpuFilter() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "runs": [ + { + "id": 231820, + "deviceId": 10253, + "rating": 4, + "avgFps": 28.324, + "tags": ["playable", "minor_stutter"], + "notes": "Runs well", + "configs": { + "id": "STEAM_1245620", + "containerVariant": "bionic", + "wineVersion": "proton-10.0-arm64ec-2", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.6.1", + "executablePath": "../../windows/system32/cmd.exe", + "execArgs": "/c dangerous-command", + "envVars": "UNSAFE=1", + "sessionMetadata": { + "session_length_sec": 537 + } + }, + "createdAt": "2026-04-23T18:55:11.115263+00:00", + "appVersion": "0.9.0", + "gameName": "ELDEN RING", + "device": { + "id": 10253, + "model": "samsung SM-S908U", + "gpu": "Adreno (TM) 730", + "androidVer": "16", + "soc": "Snapdragon 8 Gen 1" + } + } + ], + "total": 5, + "page": 0, + "pageSize": 20 + } + """.trimIndent(), + ), + ) + + val page = service.fetchConfigs( + gameId = 3405, + gpu = "Adreno (TM) 730", + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + ) + + assertEquals(5, page.total) + assertEquals(1, page.runs.size) + val run = page.runs.single() + assertEquals(231820L, run.id) + assertEquals(28.324, run.averageFps!!, 0.0001) + assertEquals(537L, run.sessionLengthSeconds) + assertEquals("steam", run.gameStore) + assertEquals("bionic", run.configString("containerVariant")) + assertFalse(run.config.containsKey("id")) + assertFalse(run.config.containsKey("executablePath")) + assertEquals("/c dangerous-command", run.configString("execArgs")) + assertEquals("UNSAFE=1", run.configString("envVars")) + assertEquals("Snapdragon 8 Gen 1", run.device.soc) + val request = server.takeRequest() + assertEquals("3405", request.requestUrl?.queryParameter("gameId")) + assertEquals("Adreno (TM) 730", request.requestUrl?.queryParameter("gpu")) + assertEquals("rating", request.requestUrl?.queryParameter("sort")) + assertEquals("desc", request.requestUrl?.queryParameter("dir")) + assertEquals("20", request.requestUrl?.queryParameter("limit")) + } + + @Test + fun fetchConfigs_omitsGpuAndSupportsNewestSort() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "runs": [], "total": 0, "page": 1, "pageSize": 20 }""", + ), + ) + + service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.NEWEST, + page = 1, + ) + + val request = server.takeRequest() + assertNull(request.requestUrl?.queryParameter("gpu")) + assertEquals("created_at", request.requestUrl?.queryParameter("sort")) + assertEquals("1", request.requestUrl?.queryParameter("page")) + } + + @Test + fun searchDevices_parsesExactModelResults() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "devices": [ + { + "id": 45701, + "model": "samsung SM-F968U1", + "gpu": "Adreno (TM) 830", + "androidVer": "16", + "soc": "SM8750" + } + ] + } + """.trimIndent(), + ), + ) + + val devices = service.searchDevices("samsung SM-F968U1") + + assertEquals(45701, devices.single().id) + assertEquals("SM8750", devices.single().soc) + assertEquals( + "samsung SM-F968U1", + server.takeRequest().requestUrl?.queryParameter("model"), + ) + } + + @Test + fun findDevices_retriesModelOnlyWhenPrimaryResultsDoNotMatch() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{ + "devices": [{ + "id": 9, + "model": "samsung SM-S918U", + "gpu": "Adreno 740", + "androidVer": "16" + }] + }""", + ), + ) + server.enqueue( + MockResponse().setBody( + """{ + "devices": [{ + "id": 10, + "model": "samsung SM-S908U", + "gpu": "Adreno (TM) 730", + "androidVer": "16" + }] + }""", + ), + ) + + val result = service.findDevices( + manufacturer = "samsung", + model = "SM-S908U", + gpu = "Qualcomm Adreno 730", + androidVersion = "16", + ) + + assertEquals(listOf(10), result.map { it.id }) + assertEquals("samsung SM-S908U", server.takeRequest().requestUrl?.queryParameter("model")) + assertEquals("SM-S908U", server.takeRequest().requestUrl?.queryParameter("model")) + } + + @Test + fun fetchConfigs_deviceFilterTakesPriorityOverGpu() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "runs": [], "total": 0, "page": 0, "pageSize": 20 }""", + ), + ) + + service.fetchConfigs( + gameId = 10, + gpu = "Adreno 830", + sort = CommunityConfigSort.NEWEST, + page = 0, + deviceIds = listOf(45701), + ) + + val request = server.takeRequest().requestUrl + assertEquals("45701", request?.queryParameter("deviceId")) + assertNull(request?.queryParameter("gpu")) + } + + @Test + fun httpError_exposesStatusWithoutParsingRuns() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(503).setBody( + """{ "error": { "message": "Try later" } }""", + ), + ) + + val error = runCatching { + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + }.exceptionOrNull() + + assertTrue(error is CommunityConfigApiException) + assertEquals(503, (error as CommunityConfigApiException).statusCode) + assertEquals("Try later", error.message) + } + + @Test + fun httpError_boundsServerControlledMessage() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(503).setBody( + """{ "error": { "message": "${"x".repeat(500)}" } }""", + ), + ) + + val error = runCatching { + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + }.exceptionOrNull() + + assertTrue(error is CommunityConfigApiException) + assertEquals(256, error?.message?.length) + } + + @Test + fun configCache_reusesPagesAndCanRefreshWithoutDiscardingGameLookups() = runBlocking { + server.enqueue( + MockResponse().setBody("""{ "games": [{ "id": 10, "name": "Cached Game" }] }"""), + ) + server.enqueue( + MockResponse().setBody("""{ "runs": [], "total": 0, "page": 0, "pageSize": 20 }"""), + ) + server.enqueue( + MockResponse().setBody("""{ "runs": [], "total": 0, "page": 0, "pageSize": 20 }"""), + ) + + service.searchGames("Cached Game") + service.searchGames("Cached Game") + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + assertEquals(2, server.requestCount) + + service.clearConfigCache() + service.searchGames("Cached Game") + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + + assertEquals(3, server.requestCount) + assertEquals("/api/games/search?q=Cached%20Game", server.takeRequest().path) + assertTrue(server.takeRequest().path.orEmpty().startsWith("/api/compatibility?")) + assertTrue(server.takeRequest().path.orEmpty().startsWith("/api/compatibility?")) + } + + @Test + fun requestThrottle_reservesHeadroomWithinRollingWindow() { + var nowNanos = 0L + val sleeps = mutableListOf() + val throttle = CommunityRequestThrottle( + maxRequests = 4, + windowMillis = 10_000L, + nanoTime = { nowNanos }, + sleepNanos = { + sleeps += it + nowNanos += it + }, + ) + + repeat(5) { throttle.awaitPermit() } + + assertEquals(listOf(TimeUnit.SECONDS.toNanos(10)), sleeps) + } + + @Test + fun requestThrottle_honorsServerCooldown() { + var nowNanos = 0L + val sleeps = mutableListOf() + val throttle = CommunityRequestThrottle( + maxRequests = 4, + windowMillis = 10_000L, + nanoTime = { nowNanos }, + sleepNanos = { + sleeps += it + nowNanos += it + }, + ) + + throttle.awaitPermit() + throttle.postpone(7_000L) + throttle.awaitPermit() + + assertEquals(listOf(TimeUnit.SECONDS.toNanos(7)), sleeps) + } + + @Test + fun requestThrottle_allowsCooldownUpdatesWhileAnotherRequestWaits() { + val now = AtomicLong(0L) + val waitStarted = CountDownLatch(1) + val allowWake = CountDownLatch(1) + val cooldownRecorded = CountDownLatch(1) + val throttle = CommunityRequestThrottle( + maxRequests = 1, + windowMillis = 10, + nanoTime = now::get, + sleepNanos = { duration -> + waitStarted.countDown() + allowWake.await(2, TimeUnit.SECONDS) + now.addAndGet(duration) + }, + ) + throttle.awaitPermit() + + val waitingRequest = Thread { throttle.awaitPermit() }.apply { start() } + assertTrue(waitStarted.await(2, TimeUnit.SECONDS)) + val cooldownUpdate = Thread { + throttle.postpone(20) + cooldownRecorded.countDown() + }.apply { start() } + + try { + assertTrue( + "Cooldown updates should not wait for a sleeping request", + cooldownRecorded.await(2, TimeUnit.SECONDS), + ) + } finally { + allowWake.countDown() + waitingRequest.join(2_000) + cooldownUpdate.join(2_000) + } + assertFalse(waitingRequest.isAlive) + assertFalse(cooldownUpdate.isAlive) + assertEquals(TimeUnit.MILLISECONDS.toNanos(20), now.get()) + } + + @Test + fun rateLimitWithoutRetryAfter_usesFullWindowCooldown() = runBlocking { + var nowNanos = 0L + val sleeps = mutableListOf() + val throttle = CommunityRequestThrottle( + maxRequests = 4, + windowMillis = 10_000L, + nanoTime = { nowNanos }, + sleepNanos = { + sleeps += it + nowNanos += it + }, + ) + val rateLimitedService = CommunityConfigService( + client = httpClient, + baseUrl = server.url("/").toString().trimEnd('/'), + requestThrottle = throttle, + ) + server.enqueue(MockResponse().setResponseCode(429).setBody("""{ "error": "Slow down" }""")) + server.enqueue(MockResponse().setBody("""{ "games": [] }""")) + + val error = runCatching { + rateLimitedService.searchGames("First") + }.exceptionOrNull() + rateLimitedService.searchGames("Second") + + assertTrue(error is CommunityConfigApiException) + assertEquals(429, (error as CommunityConfigApiException).statusCode) + assertEquals(listOf(TimeUnit.SECONDS.toNanos(10)), sleeps) + } + + @Test + fun retryAfterParser_supportsSecondsAndHttpDates() { + val retryAt = DateTimeFormatter.RFC_1123_DATE_TIME.format( + Instant.ofEpochSecond(20).atZone(ZoneOffset.UTC), + ) + + assertEquals(3_000L, parseCommunityRetryAfterMillis("3", nowEpochMillis = 0L)) + assertEquals(60_000L, parseCommunityRetryAfterMillis("999", nowEpochMillis = 0L)) + assertEquals(15_000L, parseCommunityRetryAfterMillis(retryAt, nowEpochMillis = 5_000L)) + assertNull(parseCommunityRetryAfterMillis(retryAt, nowEpochMillis = 21_000L)) + assertNull(parseCommunityRetryAfterMillis("-1", nowEpochMillis = 0L)) + assertNull(parseCommunityRetryAfterMillis("not-a-delay", nowEpochMillis = 0L)) + } + + @Test + fun selectCommunityGame_prefersNormalizedExactTitle() { + val games = listOf( + CommunityGame(1, "ELDEN RING NIGHTREIGN"), + CommunityGame(2, "Elden Ring"), + ) + + assertEquals(2, selectCommunityGame("ELDEN RING", games)?.id) + assertNull(selectCommunityGame("ELDEN RING DELUXE", games)) + assertNull(selectCommunityGame("ELDEN RING", emptyList())) + } + + @Test + fun gpuMatcher_normalizesKnownVendorFormattingAndFallsBackSafely() { + assertEquals("adreno:730", canonicalCommunityGpu("Qualcomm Adreno (TM) 730")) + assertEquals("arm:g715", canonicalCommunityGpu("Immortalis-G715 MC11")) + assertEquals( + "exact_gpu_match", + communityConfigMatchType("Qualcomm Adreno 730", "Adreno (TM) 730"), + ) + assertEquals( + "gpu_family_match", + communityConfigMatchType("Adreno 730", "Adreno 740"), + ) + assertEquals( + "gpu_family_match", + communityConfigMatchType("Adreno 830", "Adreno 840"), + ) + assertEquals( + "fallback_match", + communityConfigMatchType("Adreno 730", "Adreno 830"), + ) + assertEquals(CommunityGpuCompatibility.ADRENO_STANDARD, communityGpuCompatibility("Adreno 619")) + assertEquals(CommunityGpuCompatibility.ADRENO_ELITE, communityGpuCompatibility("Adreno (TM) A12")) + assertEquals(CommunityGpuCompatibility.ADRENO_ELITE, communityGpuCompatibility("Adreno 850")) + assertEquals(CommunityGpuCompatibility.OTHER, communityGpuCompatibility("Adreno 825")) + assertEquals(CommunityGpuCompatibility.OTHER, communityGpuCompatibility("Mali-G715")) + assertTrue(canonicalCommunityGpu("Unknown GPU").isBlank()) + } + + @Test + fun fetchCompatibleConfigs_filtersOneLargeSourcePageWithoutEagerScanning() = runBlocking { + fun run(id: Int, rating: Int, gpu: String) = + """{ + "id": $id, + "rating": $rating, + "configs": { + "containerVariant": "bionic", + "wineVersion": "wine", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.6" + }, + "createdAt": "2026-01-0${id}T00:00:00Z", + "device": {"id": $id, "model": "test", "gpu": "$gpu"} + }""" + server.enqueue( + MockResponse().setBody( + """{ + "runs": [ + ${run(1, 3, "Adreno 830")}, + ${run(2, 5, "Adreno 730")}, + ${run(3, 4, "Adreno 840")} + ], + "total": 250, + "page": 0, + "pageSize": 200 + }""", + ), + ) + + val result = service.fetchCompatibleConfigs( + gameId = 10, + currentGpu = "Adreno (TM) 830", + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + ) + + assertEquals(listOf(3L, 1L), result.runs.map { it.id }) + assertEquals(2, result.total) + assertTrue(result.hasMore) + assertEquals(1, server.requestCount) + val request = server.takeRequest().requestUrl + assertEquals("Adreno", request?.queryParameter("gpu")) + assertEquals("200", request?.queryParameter("limit")) + assertEquals("0", request?.queryParameter("page")) + } + + @Test + fun fetchCompatibleConfigs_keepsPaginationAvailableWhenCurrentPageHasNoMatches() = runBlocking { + server.enqueue( + MockResponse().setBody( + configPage( + runId = 1, + rating = 5, + deviceId = 10, + total = 201, + pageSize = 200, + gpu = "Adreno 730", + ), + ), + ) + + val result = service.fetchCompatibleConfigs( + gameId = 10, + currentGpu = "Adreno 830", + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + ) + + assertTrue(result.runs.isEmpty()) + assertTrue(result.hasMore) + } + + @Test + fun explicitRunMetadata_takesPriorityOverLegacyConfigInference() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{ + "runs": [{ + "id": 1, + "sessionLengthSec": "120", + "gameStore": "Epic Games Store", + "configs": { + "id": "STEAM_10", + "containerVariant": "bionic", + "wineVersion": "wine", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.6" + }, + "device": {"id": 1, "model": "test", "gpu": "Adreno 830"} + }], + "total": 1, + "page": 0, + "pageSize": 20 + }""", + ), + ) + + val run = service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0).runs.single() + + assertEquals(120L, run.sessionLengthSeconds) + assertEquals("epic", run.gameStore) + } + + @Test + fun deviceMatcher_returnsEveryCompatibleRecordForThePhysicalModel() { + val devices = listOf( + CommunityConfigDevice(1, "samsung SM-S908U", "Adreno 730", "15", "SM8450"), + CommunityConfigDevice(2, "samsung SM-S908U", "Adreno (TM) 730", "16", "SM8450"), + CommunityConfigDevice(3, "samsung SM-S908U", "", "16", ""), + CommunityConfigDevice(4, "samsung SM-S908U", "Mali-G715", "16", ""), + CommunityConfigDevice(5, "samsung SM-S918U", "Adreno 740", "16", ""), + ) + + val matches = selectCommunityDevices( + devices = devices, + manufacturer = "samsung", + model = "SM-S908U", + currentGpu = "Qualcomm Adreno 730", + androidVersion = "16", + ) + + assertEquals(listOf(2, 1, 3), matches.map { it.id }) + assertEquals("samsung SM-F968U1", communityDeviceQuery("samsung", "SM-F968U1")) + assertEquals("AYN Odin2", communityDeviceQuery("AYN", "AYN Odin2")) + } + + @Test + fun deviceMatcher_rejectsKnownIncompatibleGpus() { + val matches = selectCommunityDevices( + devices = listOf( + CommunityConfigDevice(1, "samsung SM-S908U", "Mali-G715", "16", ""), + CommunityConfigDevice(2, "samsung SM-S908U", "Xclipse 920", "16", ""), + ), + manufacturer = "samsung", + model = "SM-S908U", + currentGpu = "Adreno 730", + androidVersion = "16", + ) + + assertTrue(matches.isEmpty()) + } + + @Test + fun fetchConfigs_aggregatesCompatibleDeviceIdsAndSortsRuns() = runBlocking { + server.enqueue(MockResponse().setBody(configPage(runId = 1, rating = 3, deviceId = 11, total = 1))) + server.enqueue(MockResponse().setBody(configPage(runId = 2, rating = 5, deviceId = 12, total = 1))) + + val result = service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + deviceIds = listOf(11, 12), + ) + + assertEquals(listOf(2L, 1L), result.runs.map { it.id }) + assertEquals(2, result.total) + assertFalse(result.hasMore) + val requestedIds = listOf(server.takeRequest(), server.takeRequest()) + .mapNotNull { it.requestUrl?.queryParameter("deviceId") } + .toSet() + assertEquals(setOf("11", "12"), requestedIds) + } + + @Test + fun fetchConfigs_keepsCollidingRunIdsFromDifferentDevices() = runBlocking { + server.enqueue(MockResponse().setBody(configPage(runId = 1, rating = 3, deviceId = 11, total = 1))) + server.enqueue(MockResponse().setBody(configPage(runId = 1, rating = 5, deviceId = 12, total = 1))) + + val result = service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + deviceIds = listOf(11, 12), + ) + + assertEquals(listOf(12, 11), result.runs.map { it.device.id }) + assertEquals(2, result.runs.size) + } + + @Test + fun fetchConfigs_runsPermittedDeviceRequestsConcurrently() = runBlocking { + val requestsStarted = CountDownLatch(2) + val timedOutWaitingForConcurrency = AtomicBoolean(false) + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requestsStarted.countDown() + if (!requestsStarted.await(2, TimeUnit.SECONDS)) { + timedOutWaitingForConcurrency.set(true) + } + val deviceId = request.requestUrl?.queryParameter("deviceId")?.toIntOrNull() ?: 0 + return MockResponse().setBody( + configPage(runId = deviceId.toLong(), rating = 5, deviceId = deviceId, total = 1), + ) + } + } + + val result = service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.HIGHEST_RATED, + page = 0, + deviceIds = listOf(11, 12), + ) + + assertEquals(2, result.runs.size) + assertFalse("Device requests should execute concurrently", timedOutWaitingForConcurrency.get()) + } + + @Test + fun fetchConfigs_paginatesCompatibleDevicesAsOneGlobalList() = runBlocking { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val deviceId = request.requestUrl?.queryParameter("deviceId")?.toIntOrNull() + val page = request.requestUrl?.queryParameter("page")?.toIntOrNull() + val run = when (deviceId to page) { + 11 to 0 -> Triple(1L, 5, 11) + 11 to 1 -> Triple(3L, 3, 11) + 12 to 0 -> Triple(2L, 4, 12) + 12 to 1 -> Triple(4L, 2, 12) + else -> return MockResponse().setResponseCode(404) + } + return MockResponse().setBody( + configPage( + runId = run.first, + rating = run.second, + deviceId = run.third, + total = 2, + page = page ?: 0, + pageSize = 1, + ), + ) + } + } + + val pages = (0..3).map { page -> + service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.HIGHEST_RATED, + page = page, + limit = 1, + deviceIds = listOf(11, 12), + ) + } + + assertEquals(listOf(1L, 2L, 3L, 4L), pages.flatMap { it.runs }.map { it.id }) + assertTrue(pages.take(3).all { it.hasMore }) + assertFalse(pages.last().hasMore) + assertTrue(pages.all { it.runs.size == 1 }) + assertEquals(4, pages.last().total) + assertEquals(4, server.requestCount) + } + + @Test + fun fetchConfigs_capsDuplicateHeavyDevicePagination() = runBlocking { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val deviceId = request.requestUrl?.queryParameter("deviceId")?.toIntOrNull() ?: 0 + val page = request.requestUrl?.queryParameter("page")?.toIntOrNull() ?: 0 + return MockResponse().setBody( + configPage( + runId = deviceId.toLong(), + rating = 5, + deviceId = deviceId, + total = Int.MAX_VALUE, + page = page, + pageSize = 1, + ), + ) + } + } + + val result = service.fetchConfigs( + gameId = 10, + gpu = null, + sort = CommunityConfigSort.HIGHEST_RATED, + page = 30, + limit = 1, + deviceIds = listOf(11, 12), + ) + + assertTrue(result.runs.isEmpty()) + assertFalse(result.hasMore) + assertEquals(50, server.requestCount) + } + + @Test + fun nullableMetadata_isParsedAsEmptyInsteadOfLiteralNull() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{ + "runs": [{ + "id": 1, + "rating": 3, + "tags": [null, "playable"], + "notes": null, + "appVersion": null, + "configs": { + "containerVariant": "bionic", + "wineVersion": "wine", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.6" + }, + "device": { + "id": 11, + "model": "test", + "gpu": null, + "androidVer": null, + "soc": null + } + }], + "total": 1, + "page": 0, + "pageSize": 20 + }""", + ), + ) + + val run = service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0).runs.single() + + assertEquals("", run.notes) + assertEquals("", run.appVersion) + assertNull(run.sessionLengthSeconds) + assertEquals("", run.gameStore) + assertEquals(listOf("playable"), run.tags) + assertEquals("", run.device.gpu) + assertEquals("", run.device.androidVersion) + assertEquals("", run.device.soc) + } + + @Test + fun malformedRuns_doNotKeepPaginationAliveAfterServerEnds() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{ "runs": [{ "id": 1, "configs": null }], "total": 1, "page": 0, "pageSize": 1 }""", + ), + ) + + val result = service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + + assertTrue(result.runs.isEmpty()) + assertFalse(result.hasMore) + } + + @Test + fun oversizedResponse_isRejectedBeforeParsing() = runBlocking { + server.enqueue(MockResponse().setBody("x".repeat(4 * 1024 * 1024 + 1))) + + val error = runCatching { + service.fetchConfigs(10, null, CommunityConfigSort.NEWEST, 0) + }.exceptionOrNull() + + assertTrue(error is CommunityConfigApiException) + assertEquals("Compatibility response is too large", error?.message) + } + + @Test + fun communityConfigValidation_rejectsMissingRuntimeFieldsAndGlibcWhenUnsupported() { + val unsafe = kotlinx.serialization.json.Json.parseToJsonElement( + """{ + "containerVariant":"bionic", + "wineVersion":"wine", + "dxwrapper":"dxvk", + "dxwrapperConfig":"version=2.6", + "graphicsDriverConfig":{"version":"nested-values-are-not-accepted"}, + "executablePath":"cmd.exe", + "cpuList":"0" + }""", + ).jsonObject + + val sanitized = sanitizeCommunityConfig(unsafe) + assertTrue(isValidCommunityConfig(sanitized, allowGlibc = false)) + assertFalse(sanitized.containsKey("executablePath")) + assertFalse(sanitized.containsKey("cpuList")) + assertFalse(sanitized.containsKey("graphicsDriverConfig")) + assertFalse(isValidCommunityConfig(JsonObject(sanitized - "dxwrapperConfig"), allowGlibc = false)) + + val glibc = kotlinx.serialization.json.Json.parseToJsonElement( + """{"containerVariant":"glibc","dxwrapper":"dxvk","dxwrapperConfig":"version=2.6"}""", + ).jsonObject + assertFalse(isValidCommunityConfig(glibc, allowGlibc = false)) + assertTrue(isValidCommunityConfig(glibc, allowGlibc = true)) + } + + @Test + fun communityConfigSanitizer_keepsGameVariablesAndRejectsProcessControl() { + val config = kotlinx.serialization.json.Json.parseToJsonElement( + """{ + "containerVariant":"bionic", + "wineVersion":"wine", + "dxwrapper":"dxvk", + "dxwrapperConfig":"version=2.6", + "execArgs":"-dx11 -windowed", + "envVars":"GAME_FIX=1 WINEDLLOVERRIDES=xaudio2_7=n,b LD_PRELOAD=evil PATH=/tmp GUEST_PROGRAM_LAUNCHER_COMMAND=evil" + }""", + ).jsonObject + + val sanitized = sanitizeCommunityConfig(config) + + assertEquals("-dx11 -windowed", sanitized["execArgs"]?.jsonPrimitive?.content) + assertEquals( + "GAME_FIX=1 WINEDLLOVERRIDES=xaudio2_7=n,b", + sanitized["envVars"]?.jsonPrimitive?.content, + ) + } + + @Test + fun communityConfigSanitizer_rejectsMalformedLaunchSettings() { + val config = kotlinx.serialization.json.Json.parseToJsonElement( + """{ + "containerVariant":"bionic", + "wineVersion":"wine", + "dxwrapper":"dxvk", + "dxwrapperConfig":"version=2.6", + "execArgs":"-dx11\n--unexpected", + "envVars":"GOOD=1 BAD-NAME=2 MULTILINE=line\nvalue" + }""", + ).jsonObject + + val sanitized = sanitizeCommunityConfig(config) + + assertFalse(sanitized.containsKey("execArgs")) + assertEquals("GOOD=1", sanitized["envVars"]?.jsonPrimitive?.content) + } + + private fun configPage( + runId: Long, + rating: Int, + deviceId: Int, + total: Int, + page: Int = 0, + pageSize: Int = 20, + gpu: String = "Adreno 730", + ): String = + """{ + "runs": [{ + "id": $runId, + "deviceId": $deviceId, + "rating": $rating, + "configs": { + "containerVariant": "bionic", + "wineVersion": "wine", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.6" + }, + "createdAt": "2026-01-0${runId}T00:00:00Z", + "device": {"id": $deviceId, "model": "test", "gpu": "$gpu", "androidVer": "16"} + }], + "total": $total, + "page": $page, + "pageSize": $pageSize + }""" +} diff --git a/app/src/test/java/app/gamenative/utils/BestConfigServiceTest.kt b/app/src/test/java/app/gamenative/utils/BestConfigServiceTest.kt index 767ab768bc..f42d1a4a3a 100644 --- a/app/src/test/java/app/gamenative/utils/BestConfigServiceTest.kt +++ b/app/src/test/java/app/gamenative/utils/BestConfigServiceTest.kt @@ -5,14 +5,11 @@ import android.content.res.Resources import androidx.test.core.app.ApplicationProvider import app.gamenative.BuildConfig import app.gamenative.PrefManager -import com.winlator.container.Container -import com.winlator.container.ContainerData -import com.winlator.contents.AdrenotoolsManager +import java.io.File import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject -import java.io.File import org.junit.Assert.* import org.junit.Assume.assumeFalse import org.junit.Before @@ -293,16 +290,17 @@ class BestConfigServiceTest { "wineVersion": "invalid-wine-version", "dxwrapper": "dxvk", "dxwrapperConfig": "version=999.999.999", - "containerVariant": "glibc", + "containerVariant": "bionic", "graphicsDriver": "turnip", "graphicsDriverConfig": "version=999.999.999" } """.trimIndent() val bestConfig = Json.parseToJsonElement(invalidConfigJson).jsonObject - val result = runBlocking { BestConfigService.parseConfigToContainerData(context, bestConfig, "exact_gpu_match", true) } + val parsed = runBlocking { BestConfigService.parseConfigResult(context, bestConfig, "exact_gpu_match", true) } - assertTrue("Result should not be null", result!!.isEmpty()) + assertTrue("Invalid config should not produce updates", parsed.config.isEmpty()) + assertTrue("Missing components should be returned with this parse", parsed.missingComponents.isNotEmpty()) } @Test @@ -1049,6 +1047,29 @@ class BestConfigServiceTest { assertNull(ManifestComponentHelper.findManifestEntryForVersion("nope", listOf(entry))) } + @Test + fun fallbackFilter_keepsRuntimeVariantButRemovesGpuSpecificSettings() { + val config = Json.parseToJsonElement( + """{ + "containerVariant":"bionic", + "wineVersion":"wine", + "graphicsDriver":"turnip", + "graphicsDriverConfig":"version=test", + "dxwrapper":"dxvk", + "dxwrapperConfig":"version=test" + }""", + ).jsonObject + + val filtered = BestConfigService.filterConfigByMatchType(config, "fallback_match") + + assertEquals("bionic", filtered["containerVariant"]?.toString()?.trim('"')) + assertEquals("wine", filtered["wineVersion"]?.toString()?.trim('"')) + assertFalse(filtered.containsKey("graphicsDriver")) + assertFalse(filtered.containsKey("graphicsDriverConfig")) + assertFalse(filtered.containsKey("dxwrapper")) + assertFalse(filtered.containsKey("dxwrapperConfig")) + } + /** * Fresh-install guard: the wrapper drivers that ContainerUtils.setContainerDefaults assigns must * each resolve to a manifest entry AND equal that entry's id. On a fresh install the driver is diff --git a/app/src/test/java/app/gamenative/utils/CommunityConfigApplicationTest.kt b/app/src/test/java/app/gamenative/utils/CommunityConfigApplicationTest.kt new file mode 100644 index 0000000000..eb8ba9c8e9 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/CommunityConfigApplicationTest.kt @@ -0,0 +1,208 @@ +package app.gamenative.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.gamenative.PrefManager +import app.gamenative.api.prepareCommunityConfigForApply +import app.gamenative.api.sanitizeCommunityConfig +import com.winlator.container.ContainerData +import java.io.File +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CommunityConfigApplicationTest { + private lateinit var context: Context + + private val config = Json.parseToJsonElement( + """ + { + "graphicsDriver": "wrapper", + "graphicsDriverVersion": "Turnip Adreno Driver T26 (@Mr_Purple_666)", + "graphicsDriverConfig": "version=Turnip Adreno Driver T26 (@Mr_Purple_666);presentMode=mailbox", + "dxwrapper": "dxvk", + "dxwrapperConfig": "version=2.4.1,async=1,vkd3dVersion=2.14.1", + "startupSelection": 2, + "box64Version": "0.4.2", + "box64Preset": "COMPATIBILITY", + "containerVariant": "bionic", + "wineVersion": "proton-9.0-arm64ec", + "emulator": "FEXCore", + "fexcoreVersion": "2605", + "fexcoreTSOMode": "Strict", + "fexcoreX87Mode": "Slow", + "fexcoreMultiBlock": "Enabled", + "fexcorePreset": "INTERMEDIATE", + "useLegacyDRM": true, + "audioDriver": "alsa", + "wincomponents": "direct3d=0,directsound=0", + "videoMemorySize": "4096", + "execArgs": "-dx11 -windowed", + "screenSize": "640x480", + "envVars": "GAME_FIX=1 WINEDLLOVERRIDES=xaudio2_7=n,b" + } + """.trimIndent(), + ).jsonObject + + private val allowedKeys = setOf( + "graphicsDriver", + "graphicsDriverVersion", + "graphicsDriverConfig", + "dxwrapper", + "dxwrapperConfig", + "startupSelection", + "box64Version", + "box64Preset", + "containerVariant", + "wineVersion", + "emulator", + "fexcoreVersion", + "fexcoreTSOMode", + "fexcoreX87Mode", + "fexcoreMultiBlock", + "fexcorePreset", + "useLegacyDRM", + "audioDriver", + "wincomponents", + "videoMemorySize", + "execArgs", + "envVars", + ) + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + PrefManager.init(context) + + val workingDir = File(requireNotNull(System.getProperty("user.dir"))) + val manifestFile = listOf( + File(workingDir, "manifest.json"), + File(workingDir.parentFile, "manifest.json"), + ).firstOrNull { it.exists() } + if (manifestFile != null) { + PrefManager.componentManifestJson = manifestFile.readText() + PrefManager.componentManifestFetchedAt = System.currentTimeMillis() + } + } + + @Test + fun allAllowedCommunityFieldsReachContainerUnchanged() = runBlocking { + val sanitized = sanitizeCommunityConfig(config) + assertEquals(allowedKeys, sanitized.keys) + + val result = BestConfigService.parseConfigResult( + context = context, + configJson = sanitized, + matchType = "fallback_match", + applyKnownConfig = true, + storeMatch = false, + matchedGpu = "Adreno (TM) 840", + preserveConfigValues = true, + ) + + assertTrue(result.missingComponents.isEmpty()) + assertEquals(allowedKeys, result.config.keys) + + val updated = ContainerUtils.applyBestConfigMapToContainerData( + containerData = ContainerData(startupSelection = 0), + bestConfigMap = result.config, + ) + + assertEquals("wrapper", updated.graphicsDriver) + assertEquals("Turnip Adreno Driver T26 (@Mr_Purple_666)", updated.graphicsDriverVersion) + assertEquals( + "version=Turnip Adreno Driver T26 (@Mr_Purple_666);presentMode=mailbox", + updated.graphicsDriverConfig, + ) + assertEquals("dxvk", updated.dxwrapper) + assertEquals("version=2.4.1,async=1,vkd3dVersion=2.14.1", updated.dxwrapperConfig) + assertEquals(2, updated.startupSelection.toInt()) + assertEquals("0.4.2", updated.box64Version) + assertEquals("COMPATIBILITY", updated.box64Preset) + assertEquals("bionic", updated.containerVariant) + assertEquals("proton-9.0-arm64ec", updated.wineVersion) + assertEquals("FEXCore", updated.emulator) + assertEquals("2605", updated.fexcoreVersion) + assertEquals("Strict", updated.fexcoreTSOMode) + assertEquals("Slow", updated.fexcoreX87Mode) + assertEquals("Enabled", updated.fexcoreMultiBlock) + assertEquals("INTERMEDIATE", updated.fexcorePreset) + assertTrue(updated.useLegacyDRM) + assertEquals("alsa", updated.audioDriver) + assertEquals("direct3d=0,directsound=0", updated.wincomponents) + assertEquals("4096", updated.videoMemorySize) + assertEquals("-dx11 -windowed", updated.execArgs) + assertEquals("GAME_FIX=1 WINEDLLOVERRIDES=xaudio2_7=n,b", updated.envVars) + } + + @Test + fun optionalLaunchSettingsRequireExplicitSelection() { + val excluded = prepareCommunityConfigForApply( + config = config, + applyLaunchArguments = false, + applyEnvironmentVariables = false, + ) + assertFalse(excluded.containsKey("execArgs")) + assertFalse(excluded.containsKey("envVars")) + + val included = prepareCommunityConfigForApply( + config = config, + applyLaunchArguments = true, + applyEnvironmentVariables = true, + ) + assertEquals("-dx11 -windowed", included["execArgs"]?.toString()?.trim('"')) + assertEquals( + "GAME_FIX=1 WINEDLLOVERRIDES=xaudio2_7=n,b", + included["envVars"]?.toString()?.trim('"'), + ) + } + + @Test + fun preserveModeIsOptInAndKnownConfigFilteringRemainsDefault() = runBlocking { + val sanitized = sanitizeCommunityConfig(config) + + val knownConfigResult = BestConfigService.parseConfigResult( + context = context, + configJson = sanitized, + matchType = "fallback_match", + applyKnownConfig = true, + matchedGpu = "", + ) + assertFalse(knownConfigResult.config.containsKey("graphicsDriver")) + assertFalse(knownConfigResult.config.containsKey("graphicsDriverVersion")) + assertFalse(knownConfigResult.config.containsKey("graphicsDriverConfig")) + assertFalse(knownConfigResult.config.containsKey("dxwrapper")) + assertFalse(knownConfigResult.config.containsKey("dxwrapperConfig")) + + val communityResult = BestConfigService.parseConfigResult( + context = context, + configJson = sanitized, + matchType = "fallback_match", + applyKnownConfig = true, + matchedGpu = "Adreno (TM) 840", + preserveConfigValues = true, + ) + assertEquals("wrapper", communityResult.config["graphicsDriver"]) + assertEquals( + "Turnip Adreno Driver T26 (@Mr_Purple_666)", + communityResult.config["graphicsDriverVersion"], + ) + assertTrue( + (communityResult.config["graphicsDriverConfig"] as String) + .contains("Turnip Adreno Driver T26 (@Mr_Purple_666)"), + ) + assertEquals("dxvk", communityResult.config["dxwrapper"]) + assertEquals( + "version=2.4.1,async=1,vkd3dVersion=2.14.1", + communityResult.config["dxwrapperConfig"], + ) + } +}