From 1fbe103ffebe611236155b475cd2dfe545c5c2b7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:23:41 -0400 Subject: [PATCH] feat: auto driver picks come from a Drivers-CI manifest with per-GPU fallbacks (#977) --- .../com/droiddeck/launcher/DriverMenus.kt | 11 ++- .../droiddeck/launcher/core/DeviceReport.kt | 3 +- .../droiddeck/launcher/core/DownloadSource.kt | 1 + .../com/droiddeck/launcher/gpu/AutoPicks.kt | 64 ++++++++++++++++++ .../com/droiddeck/launcher/gpu/DriverPairs.kt | 18 +++-- .../droiddeck/launcher/gpu/TurnipReleases.kt | 8 +++ .../droiddeck/launcher/gpu/AutoPicksTest.kt | 67 +++++++++++++++++++ 7 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 631d55ccf..7ccbba7f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableIntStateOf import com.droiddeck.launcher.gpu.AutoDriverTrial +import com.droiddeck.launcher.gpu.AutoPicks import com.droiddeck.launcher.gpu.DriverBundle import com.droiddeck.launcher.gpu.DriverPairs import com.droiddeck.launcher.gpu.GpuInfo @@ -179,7 +180,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl val lm = LinuxVulkanDriverManager(activity) val td = TurnipDriver(activity) val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs) + val recommended = if (QuestExperimental.isSupported()) null else recommendedKey(pairs) pairRows = pairs.map { p -> val bundle = p.bundle?.let { installedBundle(it) } val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) } @@ -196,6 +197,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl }.sortedByDescending { it.recommended } } + private fun recommendedKey(pairs: List) = DriverPairs.recommendedKey( + gpu, pairs, TurnipReleases.autoPicks(activity), AutoDriverTrial.bad(activity), + blob = AutoPicks.blobVersion(com.droiddeck.launcher.gpu.VulkanInfo.cachedOrNull()?.get("driver_version")), + ) + fun chooseMode(next: String) { pairModeOpen = next == GPU_MODE_PAIR setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX) @@ -255,12 +261,13 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl Thread({ val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity) + runCatching { com.droiddeck.launcher.gpu.VulkanInfo.query() } ui.post { releaseChecking = false refreshReleaseRows() refreshPairs() val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val key = DriverPairs.recommendedKey(gpu, pairs) + val key = recommendedKey(pairs) val pair = pairs.firstOrNull { it.key == key } val row = pairRows.firstOrNull { it.key == key } when { diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 44c19ee61..1290884bb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -202,7 +202,8 @@ object DeviceReport { k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") k("Driver mode", SessionPrefs.gpuDriverMode(context) + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( - com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: "")) + com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context), + com.droiddeck.launcher.gpu.AutoDriverTrial.bad(context))?.let { " (recommended pair: $it)" } ?: "")) val turnip = TurnipDriver(context) val androidChoice = SessionPrefs.androidDriver(context) k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt index a613bf614..870e8ed42 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -38,6 +38,7 @@ object DownloadSource { private val CATALOGS = listOf( "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/", "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/", + "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/", ) data class Mirror(val host: String, val api: Boolean) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt new file mode 100644 index 000000000..76b6dddc9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family +import org.json.JSONArray +import org.json.JSONObject + +object AutoPicks { + const val URL = "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/main/auto-drivers.json" + const val SCHEMA = 1 + const val NEWEST_DD_TURNIP = DriverPairs.DD_TURNIP + + class Rule( + val families: Set, + val models: Set = emptySet(), + val oneUi8Gen2: Boolean? = null, + val minSdk: Int = 0, + val blobMin: Int = 0, + val blobMax: Int = Int.MAX_VALUE, + val prefer: List, + ) { + fun matches(gpu: GpuInfo, sdk: Int, blob: Int?): Boolean = + gpu.family in families && + (models.isEmpty() || gpu.model in models) && + (oneUi8Gen2 == null || gpu.oneUi8Gen2 == oneUi8Gen2) && + sdk >= minSdk && + (blobMin == 0 && blobMax == Int.MAX_VALUE || blob != null && blob in blobMin..blobMax) + } + + val BUILT_IN = listOf( + Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)), + Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)), + Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)), + Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)), + ) + + fun parse(text: String?): List? = runCatching { + val json = JSONObject(text ?: return null) + if (json.optInt("schemaVersion") != SCHEMA) return null + val rules = json.getJSONArray("rules") + (0 until rules.length()).mapNotNull { i -> rule(rules.getJSONObject(i)) }.takeIf { it.isNotEmpty() } + }.getOrNull() + + private fun rule(o: JSONObject): Rule? { + val families = strings(o.optJSONArray("families")).mapNotNull { name -> Family.values().firstOrNull { it.name == name } }.toSet() + val prefer = strings(o.optJSONArray("prefer")) + if (families.isEmpty() || prefer.isEmpty()) return null + val models = o.optJSONArray("models")?.let { a -> (0 until a.length()).map { a.getInt(it) }.toSet() } ?: emptySet() + return Rule( + families, models, + if (o.has("oneUi8Gen2")) o.getBoolean("oneUi8Gen2") else null, + o.optInt("minSdk", 0), o.optInt("blobMin", 0), o.optInt("blobMax", Int.MAX_VALUE), prefer, + ) + } + + private fun strings(a: JSONArray?): List = a?.let { (0 until it.length()).map { i -> it.getString(i) } }.orEmpty() + + fun preferences(gpu: GpuInfo, rules: List, sdk: Int, blob: Int?): List = + rules.firstOrNull { it.matches(gpu, sdk, blob) }?.prefer.orEmpty() + + fun blobVersion(driverVersion: String?): Int? = driverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt index f737b4d4c..c99bfb5f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt @@ -61,13 +61,17 @@ object DriverPairs { } } - fun recommendedKey(gpu: GpuInfo, pairs: List): String? = when (gpu.family) { - Family.NOT_ADRENO -> null - Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED - Family.A7XX_LOW -> BANNER_710 - Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER - Family.A6XX -> BANNER - Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER + fun recommendedKey( + gpu: GpuInfo, pairs: List, + rules: List = AutoPicks.BUILT_IN, bad: Set = emptySet(), + sdk: Int = android.os.Build.VERSION.SDK_INT, blob: Int? = null, + ): String? { + if (gpu.family == Family.NOT_ADRENO) return null + val keys = AutoPicks.preferences(gpu, rules, sdk, blob) + .ifEmpty { AutoPicks.preferences(gpu, AutoPicks.BUILT_IN, sdk, blob) } + .mapNotNull { if (it == AutoPicks.NEWEST_DD_TURNIP) newestDdTurnip(pairs) else it } + return keys.firstOrNull { key -> pairs.any { it.key == key && it.complete && AutoDriverTrial.release(it) !in bad } } + ?: keys.firstOrNull() } fun newestDdTurnip(pairs: List): String? = pairs diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index 0cf3225ca..43540c0e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -19,6 +19,7 @@ object TurnipReleases { private const val PREFS = "turnip_releases" private const val KEY_RELEASE = "latest" private const val KEY_DOWNLOADS = "downloads" + private const val KEY_PICKS = "autoPicks" class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, val sha256: String = "", val pair: String = "", val bundle: Boolean = false) @@ -57,6 +58,9 @@ object TurnipReleases { }, ) + fun autoPicks(context: Context): List = + AutoPicks.parse(prefs(context).getString(KEY_PICKS, null)) ?: AutoPicks.BUILT_IN + fun cached(context: Context): Check? { val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null return runCatching { parse(JSONObject(raw)) }.getOrNull() @@ -100,6 +104,10 @@ object TurnipReleases { } } if (failed.size == SOURCES.size) throw lastError ?: IOException("no source answered") + runCatching { get(context, AutoPicks.URL, AutoPicks.URL) } + .onFailure { Log.i(TAG, "auto picks: ${it.message}") } + .getOrNull()?.takeIf { AutoPicks.parse(it) != null } + ?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() } val stored = JSONObject().put("assets", assets).put("latest", latest) .put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis()) prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply() diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt new file mode 100644 index 000000000..83f32a36e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt @@ -0,0 +1,67 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AutoPicksTest { + private fun gpu(model: Int) = GpuInfo("Adreno $model", model, GpuInfo.familyOf(true, model), "", false) + + private fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + + private fun half(name: String, tag: String, linux: Boolean, pair: String) = + TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair) + + private val pairs = DriverPairs.from(TurnipReleases.Check( + listOf( + bundle("DD-Turnip-v0.3.0"), + half("Turnip-r6.zip", "r6", false, DriverPairs.BANNER), + half("Turnip-r6-Linux.zip", "r6", true, DriverPairs.BANNER), + ), + emptyList(), emptyList(), 0L, + )) + + private val manifest = """ + {"schemaVersion": 1, "rules": [ + {"families": ["A7XX"], "models": [740], "blobMin": 700, "prefer": ["banner"]}, + {"families": ["A7XX", "A6XX"], "prefer": ["dd-turnip", "banner"]}, + {"families": ["A9XX"], "prefer": ["banner"]} + ]} + """.trimIndent() + + @Test fun theManifestOrdersPreferencesAndTheNewestDdTurnipResolves() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(2, rules.size) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(650), pairs, rules)) + } + + @Test fun aReleaseMarkedBadFallsThroughToTheNextPreference() { + val rules = AutoPicks.parse(manifest)!! + val dd = pairs.first { it.key.startsWith(DriverPairs.DD_TURNIP) } + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), pairs, rules, bad = setOf(AutoDriverTrial.release(dd)))) + } + + @Test fun systemDriverRulesOnlyMatchAKnownBlob() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 744)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 676)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = null)) + assertEquals(744, AutoPicks.blobVersion("512.744.0")) + } + + @Test fun aGpuTheManifestDoesNotCoverUsesTheBuiltInTable() { + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs, AutoPicks.parse(manifest)!!)) + } + + @Test fun anotherSchemaOrNoUsableRulesIsIgnored() { + assertNull(AutoPicks.parse("""{"schemaVersion": 2, "rules": [{"families": ["A7XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("""{"schemaVersion": 1, "rules": [{"families": ["A9XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("not json")) + } +}