Compare commits

...
Author SHA1 Message Date
maxjivi05 b31ab5fa96 Merge remote-tracking branch 'origin/main' into fix/pr103-conflicts
# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/App.kt
#	app/src/main/java/com/droiddeck/launcher/MainActivity.kt
#	app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
2026-09-30 16:56:59 -04:00
Kurt Himebauch 02fa7983e9 Merge branch 'main' into feat/gpu-driver-auto 2026-09-30 16:20:58 -04:00
MaxsTechReview 5e55189d03 DD-Turnip Driver Support (#106) 2026-09-30 16:20:28 -04:00
xXJsonDeruloXx f76a58ea1e feat: pick GPU drivers as matched pairs, set automatically for the device's GPU 2026-09-30 14:32:58 -04:00
20 changed files with 1475 additions and 188 deletions

No files matched your search

@@ -16,6 +16,7 @@ class App : Application() {
CrashHandler.install(this)
// Before anything else touches the session prefs: a new install's first read decides it.
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
}
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
@@ -10,6 +10,10 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import com.droiddeck.launcher.gpu.DriverBundle
import com.droiddeck.launcher.gpu.DriverPairs
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
@@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.GpuDriversState
import com.droiddeck.launcher.ui.PairRow
import com.droiddeck.launcher.wayland.CompositorHost
/**
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
* screen keeps one and hands it to the Components page's GPU drivers tab.
*/
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
var linuxSelected by mutableStateOf("")
val gpu: GpuInfo = GpuInfo.detect()
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
/** The pair being installed, and how far along. */
var pairBusy by mutableStateOf<String?>(null)
var pairPercent by mutableIntStateOf(-1)
/** What Auto last did or found, for the line under the pair in use. */
var autoStatus by mutableStateOf("")
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
var activeBundle by mutableStateOf<String?>(null)
private var autoCheckedThisProcess = false
fun state() = GpuDriversState(
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
activeBundle = activeBundle,
)
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
}
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
else bundleRow(id, linux = true) ?: DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
linuxSelected = SessionPrefs.linuxDriver(activity)
mode = SessionPrefs.gpuDriverMode(activity)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
activeBundle = DriverBundle.active(activity)?.label
refreshReleaseRows()
refreshPairs()
}
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
fun summary(): String {
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
}
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
fun refreshPairs() {
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val recommended = DriverPairs.recommendedKey(gpu, 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) }
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
val mb = p.assets.sumOf { it.size } / 1_048_576.0
PairRow(
key = p.key, name = p.name, version = p.version,
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
installed = displayId != null && linuxId != null,
active = displayId != null && linuxId != null &&
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
)
}.sortedByDescending { it.recommended }
}
fun setMode(auto: Boolean) {
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
SessionPrefs.setGpuDriverMode(activity, mode)
autoStatus = ""
if (auto) ensureAuto(force = false)
}
/**
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
*/
fun ensureAuto(force: Boolean) {
mode = SessionPrefs.gpuDriverMode(activity)
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
if (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
return
}
releaseChecking = true
autoStatus = "Checking for the latest drivers…"
Thread({
val cached = TurnipReleases.cached(activity)
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
cached.assets.none { it.pair.isNotEmpty() }
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val key = DriverPairs.recommendedKey(gpu, pairs)
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
pair == null || !pair.complete ->
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
else -> installPair(pair, auto = true)
}
}
}, "gpu-driver-auto").start()
}
/** Manual: install (if need be) and set both halves of a pair. */
fun selectPair(key: String) {
if (pairBusy != null) return
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
installPair(pair, auto = false)
}
/**
* Download what is missing of [pair], then set both halves - only once both are in, so a
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
* ones the new pair replaces are removed.
*/
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
if (!pair.complete) return
pairBusy = pair.key
pairPercent = 0
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
Thread({
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
var downloaded = false
val result = runCatching {
val zips = pair.assets
val ids = zips.mapIndexed { i, asset ->
installedIdOf(asset)
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
}
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
}
ui.post {
pairBusy = null
pairPercent = -1
result.onSuccess { (displayId, linuxId) ->
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
if (auto) {
val previous = SessionPrefs.gpuAutoInstalled(activity)
for (id in previous - setOf(displayId, linuxId)) {
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
when {
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
lm.isInstalled(id) -> lm.removeDriver(id)
else -> td.remove(id)
}
TurnipReleases.forget(activity, id)
}
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
} else {
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
}
}.onFailure { e ->
Log.w(TAG, "driver pair ${pair.key}", e)
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
if (auto) autoStatus = why ?: "Download failed"
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
}
refreshDrivers()
}
}, "gpu-driver-pair").start()
}
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
asset.bundle -> installedBundle(asset)?.id
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
}
/**
* One release asset, downloaded, checked and installed through the importer; returns its id,
* a bundle's id for a bundle.
*/
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
var file: File? = null
try {
file = TurnipReleases.download(activity, asset, progress)
val uri = Uri.fromFile(file)
val id = when {
asset.bundle -> DriverBundle.install(activity, uri).id
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
}
TurnipReleases.recordDownload(activity, asset, id)
return id
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
*/
fun importDriver(uri: Uri, linux: Boolean) {
fun importDriver(uri: Uri, linux: Boolean?) {
val name = activity.displayNameOf(uri)
Thread({
var bundle: DriverBundle.Bundle? = null
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
when {
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
else -> try {
TurnipDriver(activity).installFromZip(uri, name)
} catch (display: IllegalArgumentException) {
try {
LinuxVulkanDriverManager(activity).installDriver(uri, name)
} catch (runtime: IllegalArgumentException) {
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
}
}
}
null
} catch (e: IllegalArgumentException) {
e.message
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
"Import failed: ${e.message}"
}
ui.post {
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
activity, problem ?: done,
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
* sets the bundle's other half in the other list as well.
*/
fun selectDriver(id: String, linux: Boolean) {
val bundle = DriverBundle.containing(activity, id, linux)
when {
bundle != null -> useBundle(bundle)
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
setMode(false)
refreshDrivers()
}
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
private fun useBundle(bundle: DriverBundle.Bundle): String {
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
DriverBundle.select(activity, bundle)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
return "Using ${bundle.label} as both the runtime and display driver." +
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
fun deleteDriver(id: String, linux: Boolean) {
val bundle = DriverBundle.containing(activity, id, linux)
if (bundle != null) {
DriverBundle.remove(activity, bundle)
TurnipReleases.forget(activity, bundle.id)
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
return
}
if (linux) {
LinuxVulkanDriverManager(activity).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
} else {
val td = TurnipDriver(activity)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.filter { it.bundle || it.linux == linux }
.filter { a -> installedIdOf(a) == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
@@ -160,6 +160,9 @@ class MainActivity : ComponentActivity() {
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
@@ -657,11 +660,11 @@ class MainActivity : ComponentActivity() {
}
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
}
@@ -681,6 +684,8 @@ class MainActivity : ComponentActivity() {
syncLossless()
// Opening the app and coming back from a session both land here.
updates.onResume()
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
drivers.ensureAuto(force = false)
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
@@ -807,14 +812,16 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
}
private fun importComponent(uri: Uri) {
@@ -849,6 +856,22 @@ class MainActivity : ComponentActivity() {
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
onPair = { key -> drivers.selectPair(key) },
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
// Picking one driver on its own is Manual: Auto would put its pair back.
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
)
}
@@ -916,9 +939,7 @@ class MainActivity : ComponentActivity() {
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
linuxRows = drivers.linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -939,8 +960,6 @@ class MainActivity : ComponentActivity() {
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
@@ -951,16 +970,8 @@ class MainActivity : ComponentActivity() {
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRefreshReleases = { drivers.checkLatestTurnip() },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -113,19 +113,24 @@ object DeviceReport {
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
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)" } ?: ""))
val turnip = TurnipDriver(context)
val androidChoice = SessionPrefs.androidDriver(context)
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
val lm = LinuxVulkanDriverManager(context)
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
val id = SessionPrefs.linuxDriver(context, m)
run {
val id = SessionPrefs.linuxDriver(context)
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
k("Linux driver ($m)", label)
k("Linux driver", label)
}
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
@@ -0,0 +1,196 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.util.zip.ZipInputStream
/**
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
*
* ```
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
* linux/ meta.json + libvulkan_freedreno.so
* ```
*
* Installing one puts each half through its own importer, with that importer's checks, and
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
*/
object DriverBundle {
private const val TAG = "DriverBundle"
private const val PREFS = "driver_bundles"
private const val MANIFEST = "manifest.json"
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
private const val MAX_BYTES = 512L shl 20
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
/** "DD-Turnip 0.1.0". */
val label: String get() = if (version.isEmpty()) name else "$name $version"
}
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
fun parseManifest(json: JSONObject): Manifest? {
val drivers = json.optJSONObject("drivers") ?: return null
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
if (android == linux) return null
return Manifest(
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
json.optString("tag"), android, linux,
)
}
private fun cleanPath(path: String): String? {
val p = path.trim().trim('/')
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
}
/** True when the zip at [uri] is a bundle rather than a single driver. */
fun isBundle(context: Context, uri: Uri): Boolean = try {
context.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input).use { zip ->
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
}
} ?: false
} catch (e: Exception) {
Log.w(TAG, "reading $uri as a bundle", e)
false
}
/**
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
* is not a usable bundle, [IOException] on read failures.
*/
fun install(context: Context, uri: Uri): Bundle {
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
val td = TurnipDriver(context)
val lm = LinuxVulkanDriverManager(context)
var androidId: String? = null
try {
unzip(context, uri, work)
val manifest = File(work, MANIFEST).takeIf { it.isFile }
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
val androidDir = File(work, manifest.androidPath)
val linuxDir = File(work, manifest.linuxPath)
val androidStage = td.newStagingDir()
androidId = try {
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
td.adopt(androidStage, manifest.name)
} catch (e: Exception) {
FileUtils.delete(androidStage)
throw e
}
val linuxStage = lm.newStagingDir()
val linuxId = try {
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
lm.adopt(linuxStage, library?.name, meta, manifest.name)
} catch (e: Exception) {
FileUtils.delete(linuxStage)
throw e
}
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
val saved = registry(context)
var id = base
var n = 2
while (saved.contains(id)) id = "$base-${n++}"
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
.put("android", androidId).put("linux", linuxId).toString()).apply()
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
return bundle
} catch (e: Exception) {
androidId?.let { td.remove(it) }
throw e
} finally {
FileUtils.delete(work)
}
}
private fun unzip(context: Context, uri: Uri, dest: File) {
val root = dest.canonicalFile
var total = 0L
context.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input).use { zip ->
for (entry in generateSequence { zip.nextEntry }) {
if (entry.isDirectory) continue
val out = File(root, entry.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
out.parentFile?.mkdirs()
out.outputStream().use { sink ->
val buf = ByteArray(1 shl 16)
while (true) {
val r = zip.read(buf)
if (r <= 0) break
total += r
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
sink.write(buf, 0, r)
}
}
}
}
} ?: throw IOException("cannot open $uri")
}
/** Every installed bundle whose two halves are both still there. */
fun all(context: Context): List<Bundle> {
val td = TurnipDriver(context)
val lm = LinuxVulkanDriverManager(context)
return registry(context).all.mapNotNull { (id, raw) ->
runCatching {
val j = JSONObject(raw as String)
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
}.getOrNull()
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
}
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
/** The bundle both drivers are set to right now, if they are one. */
fun active(context: Context): Bundle? {
val android = SessionPrefs.androidDriver(context)
val linux = SessionPrefs.linuxDriver(context)
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
}
/** Set both halves as the drivers in use. */
fun select(context: Context, bundle: Bundle) {
SessionPrefs.setAndroidDriver(context, bundle.androidId)
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
}
/** Delete both halves; a driver still set to either goes back to its default. */
fun remove(context: Context, bundle: Bundle) {
TurnipDriver(context).remove(bundle.androidId)
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
registry(context).edit().remove(bundle.id).apply()
Log.i(TAG, "removed bundle ${bundle.id}")
}
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -0,0 +1,112 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.gpu.GpuInfo.Family
/**
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
* installs and selects both halves of one pair at once.
*
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
* newest release that carries both halves.
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
* newest bundle, and it can never be mixed.
*/
object DriverPairs {
const val BANNER = "banner"
const val BANNER_A8XX = "banner-a8xx"
const val BANNER_710 = "banner-710-720-test"
const val BANNER_ONEUI = "banner-8g2-oneui"
const val WN_BALANCED = "wn-b"
const val WN_PERFORMANCE = "wn-p"
const val DD_TURNIP = "dd-turnip"
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
class DriverPair(
val key: String,
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
val name: String,
/** The families the pair is built for; the list only offers it on those unless asked. */
val families: Set<Family>,
val display: TurnipReleases.Asset?,
val linux: TurnipReleases.Asset?,
/** Both halves in one zip, instead of [display] and [linux]. */
val bundle: TurnipReleases.Asset? = null,
) {
val complete: Boolean get() = bundle != null || (display != null && linux != null)
/** The zips to download for the pair: the bundle, or the two halves. */
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
/** "r4 · 1.19 + 0.1.2": the build of each half. */
val version: String
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
fun suits(gpu: GpuInfo) = gpu.family in families ||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
}
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
private fun describe(key: String): Pair<String, Set<Family>> = when {
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
else -> describeFixed(key)
}
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
else -> key to ALL_ADRENO
}
/** Every pair the last release check found, in the order the list shows them. */
fun from(check: TurnipReleases.Check?): List<DriverPair> {
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
return assets.map { it.pair }.distinct()
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
.map { key ->
val (name, families) = describe(key)
val halves = assets.filter { it.pair == key }
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
// The list keeps the newest release of each half; for Banners-Turnip the two halves
// must come from the same release, so a set missing one half in its newest release
// is offered incomplete rather than mixed with an older one.
val display = halves.firstOrNull { !it.linux }
val linux = halves.firstOrNull { it.linux }
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
DriverPair(key, name, families, display, if (sameRelease) linux else null)
}
}
/**
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
*/
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): 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
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
}
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
.maxByOrNull { p ->
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
}?.key
}
@@ -0,0 +1,80 @@
package com.droiddeck.launcher.gpu
import android.os.Build
import com.droiddeck.launcher.core.FileUtils
import java.io.File
/**
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
* may start, but it is not "supported" just because it is a Qualcomm chip.
*/
data class GpuInfo(
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
val name: String,
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
val model: Int,
val family: Family,
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
val soc: String,
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
val oneUi8Gen2: Boolean,
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
A7XX_LOW("Adreno 710/720/722"),
A7XX("Adreno 7xx"),
A6XX("Adreno 6xx"),
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
ADRENO_UNKNOWN("Adreno"),
NOT_ADRENO("Not an Adreno GPU"),
}
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
val support: Support
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
else -> Support.UNTESTED
}
/** One line for the device card and the system check. */
val supportText: String
get() = when (support) {
Support.TESTED -> "Supported"
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
else "Below tested hardware (Adreno 725 and newer): it may not run"
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
}
companion object {
fun detect(): GpuInfo {
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
)
}
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
!adreno -> Family.NOT_ADRENO
model == 0 -> Family.ADRENO_UNKNOWN
model >= 800 -> Family.A8XX
model in listOf(710, 720, 722) -> Family.A7XX_LOW
model >= 700 -> Family.A7XX
model >= 600 -> Family.A6XX
else -> Family.ADRENO_UNKNOWN
}
}
}
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
* Linux driver; {@link IOException} on read/extract failures.
*/
public String installDriver(Uri zipUri, String displayName) throws IOException {
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
File tmpDir = newStagingDir();
boolean keep = false;
try {
String soName = null;
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
}
}
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
// library_path is relative to itself, and we write an absolute one below.
// library_path is relative to itself, and adopt() writes an absolute one.
}
}
String id = adopt(tmpDir, soName, zipMeta, displayName);
keep = true;
return id;
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
File newStagingDir() throws IOException {
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
return tmpDir;
}
/**
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
* deletes {@code tmpDir} when this throws.
*/
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
try {
if (soName == null) {
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
keep = true;
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
return id;
} catch (org.json.JSONException e) {
throw new IOException("manifest write failed: " + e.getMessage());
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
@@ -179,10 +179,7 @@ public final class TurnipDriver {
* {@link IOException} on read/extract failures.
*/
public String installFromZip(Uri zipUri, String displayName) throws IOException {
if (!contentDir.exists()) contentDir.mkdirs();
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
File tmpDir = newStagingDir();
boolean keep = false;
try {
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
@@ -198,26 +195,44 @@ public final class TurnipDriver {
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
if (name.isEmpty()) {
name = displayName != null ? displayName : "adrenotools-driver";
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
}
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
File dir = new File(contentDir, id);
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
String id = adopt(tmpDir, displayName);
keep = true;
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
return id;
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
File newStagingDir() throws IOException {
if (!contentDir.exists()) contentDir.mkdirs();
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
return tmpDir;
}
/**
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
* Returns the id; the caller deletes {@code tmpDir} when this throws.
*/
String adopt(File tmpDir, String displayName) throws IOException {
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
if (name.isEmpty()) {
name = displayName != null ? displayName : "adrenotools-driver";
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
}
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
File dir = new File(contentDir, id);
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
return id;
}
private static JSONObject readMetaFile(File metaFile) {
try {
return new JSONObject(FileUtils.readString(metaFile));
@@ -29,12 +29,20 @@ object TurnipReleases {
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
/**
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
*/
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
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 sha256: String = "", val pair: String = "", val bundle: Boolean = false)
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
private val SOURCES = listOf(
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
@@ -45,12 +53,14 @@ object TurnipReleases {
if (variant.endsWith("-Wayland")) return@Source null
val linux = variant.endsWith("-Linux")
variant = variant.removeSuffix("-Linux")
linux to when {
val label = when {
variant.isEmpty() -> "Adreno 6xx/7xx"
variant == "-A8xx" -> "Adreno 8xx"
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
else -> variant.trimStart('-')
}
Kind(linux, label, "banner" + variant.lowercase())
},
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
// b = Balanced, p = Performance, Axxx = every Adreno.
@@ -58,7 +68,13 @@ object TurnipReleases {
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
},
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
},
)
@@ -91,14 +107,19 @@ object TurnipReleases {
for (j in 0 until list.length()) {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
val kind = src.classify(name, tag) ?: continue
// A bundle is only offered from an official release, never a pre-release.
if (kind.bundle && r.optBoolean("prerelease")) continue
val linux = kind.linux
val label = kind.label
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label).put("sha256", sha))
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
.put("bundle", kind.bundle))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
@@ -136,7 +157,8 @@ object TurnipReleases {
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
a.optBoolean("bundle", false)))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
@@ -112,15 +112,52 @@ object SessionPrefs {
}
/**
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
* session's choice, the one nearly everyone set, carries over.
*/
fun linuxDriver(context: Context, mode: String): String =
prefs(context).getString("linuxDriver.$mode", "") ?: ""
fun linuxDriver(context: Context): String =
prefs(context).getString("linuxDriver", null)
?: prefs(context).getString("linuxDriver.steam", null)
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
fun setLinuxDriver(context: Context, mode: String, id: String) {
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
fun setLinuxDriver(context: Context, id: String) {
prefs(context).edit().putString("linuxDriver", id).apply()
}
const val GPU_DRIVERS_AUTO = "auto"
const val GPU_DRIVERS_MANUAL = "manual"
/**
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
* installs; see settleGpuDriverMode.
*/
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
fun setGpuDriverMode(context: Context, mode: String) {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
/**
* Auto arrived after people had picked drivers by hand: an install that chose either driver
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
*/
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
}
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
fun gpuAutoInstalled(context: Context): Set<String> =
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
@@ -503,15 +503,12 @@ class SessionService : Service() {
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
// An imported glibc Turnip, when one is set (by the user, or by Auto): the session script
// checks the manifest and its library from inside and points the loader at it with
// VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad
// import falls back to. One driver for every session: the driver's shader cache is keyed on
// its build, and with one per mode every emulator compiled its shaders twice.
val linuxDriverId = SessionPrefs.linuxDriver(this)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
@@ -102,6 +102,9 @@ fun ComponentsPage(
onImport: () -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
gpu: GpuDriversState = GpuDriversState(),
gpuActions: GpuDriversActions = GpuDriversActions(),
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -112,23 +115,55 @@ fun ComponentsPage(
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
var driverPage by remember { mutableStateOf<String?>(null) }
val gpuTab = comp == GPU_TAB
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
canRestore = gpu.canRestoreBundled,
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
}
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
// ---- title, and the one thing that goes online -----------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, modifier = Modifier.weight(1f),
maxLines = 1,
)
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
// The status gives way to the title, not the other way round.
if (!narrow) Text(
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
) else Spacer(Modifier.weight(1f))
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -138,7 +173,7 @@ fun ComponentsPage(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null) Box {
if (view != null && !gpuTab) Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
@@ -152,11 +187,12 @@ fun ComponentsPage(
}
}
}
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
val tabs = listOf(GPU_TAB) + comps
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
}
// ---- what is in use -----------------------------------------------------------------------
if (view != null) {
if (view != null && !gpuTab) {
val st = view.components.getValue(comp)
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Row(
@@ -179,6 +215,12 @@ fun ComponentsPage(
// ---- the lists ------------------------------------------------------------------------------
when {
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
Row(modifier = Modifier.padding(top = 14.dp)) {
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
}
}
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
const val GPU_TAB = "gpu"
@@ -141,6 +141,7 @@ fun DriverPage(
text = {
Text(
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default."
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
fontSize = 13.sp,
)
@@ -153,7 +153,9 @@ internal fun SetupPanel(
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val gpuOk = remember { DeviceSupport.adreno() }
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
@@ -195,8 +197,16 @@ internal fun SetupPanel(
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
if (gpuOk) "Device supported" else "GPU not supported",
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported"
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU"
else -> "GPU not supported"
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName"
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}"
else -> "Steam draws with an Adreno driver; $gpuName may show a black screen"
},
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
@@ -0,0 +1,312 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
class PairRow(
val key: String,
val name: String,
val version: String,
val detail: String,
val recommended: Boolean,
/** Built for this GPU's family; the others only show when asked. */
val suits: Boolean,
val complete: Boolean,
val installed: Boolean,
val active: Boolean,
)
class GpuDriversState(
val gpuName: String = "",
val gpuFamily: String = "",
val soc: String = "",
val supportText: String = "",
val supported: Boolean = true,
val unsupported: Boolean = false,
val auto: Boolean = true,
val pairs: List<PairRow> = emptyList(),
/** The pair key being installed, and how far along (-1 = unknown). */
val busy: String? = null,
val percent: Int = -1,
val autoStatus: String = "",
val releaseStatus: String = "",
val checking: Boolean = false,
// Advanced: each driver on its own.
val linuxRows: List<DriverRow> = emptyList(),
val linuxSelected: String = "",
val androidRows: List<DriverRow> = emptyList(),
val androidSelected: String = "",
val linuxDownloads: List<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = emptyList(),
val canRestoreBundled: Boolean = false,
/** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */
val activeBundle: String? = null,
) {
/** The pair both drivers are set to, if they make one. */
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
}
class GpuDriversActions(
val onAuto: (Boolean) -> Unit = {},
val onPair: (String) -> Unit = {},
val onRefresh: () -> Unit = {},
val onSelectLinux: (String) -> Unit = {},
val onSelectAndroid: (String) -> Unit = {},
val onRemoveLinux: (String) -> Unit = {},
val onRemoveAndroid: (String) -> Unit = {},
val onDownloadDriver: (String) -> Unit = {},
val onImportLinux: () -> Unit = {},
val onImportAndroid: () -> Unit = {},
/** The tab's own import: a bundle, or a single driver of either kind. */
val onImportZip: () -> Unit = {},
val onRestoreBundled: () -> Unit = {},
)
/**
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
* two lists each on their own, and imports, are under Advanced.
*/
@Composable
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var showAll by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
DeviceCard(s)
if (!s.unsupported) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
}
if (s.auto) AutoCard(s)
else {
val shown = s.pairs.filter { it.suits || showAll }
if (shown.isEmpty()) Text(
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
)
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
if (s.pairs.any { !it.suits }) FocusText(
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
) { showAll = !showAll }
}
}
SettingsGroup("Advanced") {
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
}
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
}
}
}
}
/**
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
*/
@Composable
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by remember { mutableStateOf(false) }
Box {
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
for (row in rows) DropdownMenuItem(
leadingIcon = { Radio(row.id == selected) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
)
HorizontalDivider(color = pal.line)
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
}
}
}
@Composable
private fun DeviceCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when {
s.unsupported -> pal.error
s.supported -> pal.good
else -> AttentionAmber
}
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
}
}
}
/** What Auto has set and what it is doing. */
@Composable
private fun AutoCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val active = s.activePair
val recommended = s.pairs.firstOrNull { it.recommended }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
)
Text(
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
if (s.busy != null) {
LinearProgressIndicator(
progress = { (s.percent.coerceAtLeast(0)) / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
}
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
}
}
@Composable
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
.clip(Shape14).background(colors.surface).background(fill)
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Radio(selected)
}
}
@Composable
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val usable = enabled && p.complete && !p.active
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
.clip(Shape12).background(colors.surface).background(fill)
.border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(enabled = usable, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Radio(p.active)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (p.recommended) Text(
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when {
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
}
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
}
@Composable
private fun Radio(selected: Boolean) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
}
}
@@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
const val BUNDLED = "BUNDLED"
const val DOWNLOADED = "DOWNLOADED"
const val IMPORTED = "IMPORTED"
/** One half of an Android + Linux bundle: picked and deleted together with the other. */
const val BUNDLE = "ANDROID + LINUX"
}
}
@@ -47,12 +49,10 @@ class ModeSettings(
val shapeMode: String,
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
val gpuDrivers: String = "Auto",
/** Frames per second the session is capped at; 0 = none. */
val fpsLimit: Int = 0,
val linuxRows: List<DriverRow>,
val linuxSelected: String,
val androidRows: List<DriverRow>,
val androidSelected: String,
val touchMode: String,
val suspendPolicy: String,
/** Steam only. */
@@ -83,12 +83,6 @@ class ModeSettings(
val addedGames: List<AddedGameRow> = emptyList(),
val addedGamesArt: Boolean = true,
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
val linuxDownloads: List<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = emptyList(),
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
val releaseChecking: Boolean = false,
/** A bundled display driver was deleted: the page offers to restore it. */
val canRestoreBundled: Boolean = false,
/** Steam only: Decky Loader is managed from the Steam session settings. */
val deckyInstalled: String? = null,
val deckyLatestRelease: DeckyManager.Release? = null,
@@ -108,17 +102,9 @@ class ModeSettingsActions(
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
val onFpsLimit: (Int) -> Unit = {},
val onSelectLinux: (String) -> Unit,
val onImportLinux: () -> Unit,
val onRemoveLinux: (String) -> Unit,
val onRefreshReleases: () -> Unit = {},
/** Asset name of the release driver to download. */
val onDownloadDriver: (String) -> Unit = {},
val onRestoreBundled: () -> Unit = {},
val onSelectAndroid: (String) -> Unit,
val onImportAndroid: () -> Unit,
val onRemoveAndroid: (String) -> Unit,
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onOsc: (String) -> Unit,
@@ -153,51 +139,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
// Coming back restores this page as it was left: the same scroll position, and controller focus
// on the driver box that opened the page.
var driverPage by remember { mutableStateOf<String?>(null) }
var returnTo by remember { mutableStateOf<String?>(null) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
androidx.compose.runtime.LaunchedEffect(driverPage) {
if (driverPage == null) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
// from a driver page, it returns to the driver box that opened it.
androidx.compose.runtime.withFrameNanos { }
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
runCatching { target.requestFocus() }
}
}
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver",
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver",
hint = "Used by the compositor in both modes. Restart the app to apply.",
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
canRestore = s.canRestoreBundled,
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
androidx.compose.runtime.LaunchedEffect(Unit) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
androidx.compose.runtime.withFrameNanos { }
runCatching { firstChip.requestFocus() }
}
SettingsPage(
host,
@@ -244,17 +192,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
SettingsGroup("Drivers") {
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
ValueChip(
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
) { openDriverPage("rt") }
}
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
ValueChip(
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
) { openDriverPage("panel") }
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
}
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
@@ -0,0 +1,133 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class DriverBundleTest {
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
File(context.filesDir, "graphics_driver").deleteRecursively()
File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively()
context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit()
SessionPrefs.setAndroidDriver(context, "")
SessionPrefs.setLinuxDriver(context, "")
}
/** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */
private fun library(libc: String) = ByteArray(64).also {
byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it)
it[18] = 0xB7.toByte()
} + libc.toByteArray() + ByteArray(1)
private val manifest = JSONObject()
.put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0")
.put("drivers", JSONObject()
.put("android", JSONObject().put("path", "android"))
.put("linux", JSONObject().put("path", "linux")))
private fun zip(entries: Map<String, ByteArray>): Uri {
val file = File.createTempFile("bundle", ".zip", context.cacheDir)
ZipOutputStream(file.outputStream()).use { z ->
for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() }
}
return Uri.fromFile(file)
}
private fun bundle(linuxLibc: String = "libc.so.6", extra: Map<String, ByteArray> = emptyMap()) = zip(mapOf(
"android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(),
"android/libvulkan_freedreno.so" to library("libc.so"),
"linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(),
"linux/libvulkan_freedreno.so" to library(linuxLibc),
"manifest.json" to manifest.toString().toByteArray(),
) + extra)
@Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() {
val m = DriverBundle.parseManifest(manifest)!!
assertEquals("android", m.androidPath)
assertEquals("linux", m.linuxPath)
assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a")))))
val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") }
assertNull(DriverBundle.parseManifest(same))
val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") }
assertNull(DriverBundle.parseManifest(escape))
}
@Test fun singleDriverZipsAreNotBundles() {
val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so")))
assertFalse(DriverBundle.isBundle(context, legacy))
assertTrue(DriverBundle.isBundle(context, bundle()))
}
@Test fun installsBothHalvesAndPicksAndDeletesThemTogether() {
val b = DriverBundle.install(context, bundle())
assertEquals("DD-Turnip-v0.1.0", b.id)
assertEquals("DD-Turnip 0.1.0", b.label)
assertTrue(TurnipDriver(context).isInstalled(b.androidId))
assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id)
assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id)
assertNull(DriverBundle.active(context))
DriverBundle.select(context, b)
assertEquals(b.androidId, SessionPrefs.androidDriver(context))
assertEquals(b.linuxId, SessionPrefs.linuxDriver(context))
assertNotNull(DriverBundle.active(context))
DriverBundle.remove(context, b)
assertFalse(TurnipDriver(context).isInstalled(b.androidId))
assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
assertEquals("", SessionPrefs.androidDriver(context))
assertEquals("", SessionPrefs.linuxDriver(context))
assertTrue(DriverBundle.all(context).isEmpty())
}
@Test fun aRefusedHalfLeavesNothingInstalled() {
try {
DriverBundle.install(context, bundle(linuxLibc = "libc.so"))
fail("a bionic library in linux/ must be refused")
} catch (e: IllegalArgumentException) {
assertTrue(e.message!!.contains("glibc"))
}
assertTrue(TurnipDriver(context).enumerateImported().isEmpty())
assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty())
}
@Test fun entriesOutsideTheBundleAreRefused() {
try {
DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4))))
fail("a zip-slip entry must be refused")
} catch (e: IllegalArgumentException) {
assertTrue(e.message!!.contains("outside"))
}
assertFalse(File(context.cacheDir.parentFile, "escape.so").exists())
}
@Test fun twoInstallsOfOneReleaseGetTheirOwnIds() {
val first = DriverBundle.install(context, bundle())
val second = DriverBundle.install(context, bundle())
assertEquals("DD-Turnip-v0.1.0-2", second.id)
assertEquals(2, DriverBundle.all(context).size)
assertFalse(first.androidId == second.androidId)
}
}
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.gpu.GpuInfo.Family
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverPairsTest {
private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) =
GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi)
private fun asset(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)
@Test
fun familiesFollowTheAdrenoModel() {
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825))
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810))
assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722))
assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740))
assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610))
assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0))
assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0))
}
@Test
fun onlyTestedHardwareCountsAsSupported() {
assertEquals(GpuInfo.Support.TESTED, gpu(740).support)
assertEquals(GpuInfo.Support.TESTED, gpu(825).support)
assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support)
assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support)
assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support)
}
@Test
fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() {
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList()))
assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList()))
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList()))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList()))
assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList()))
assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList()))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList()))
assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList()))
}
@Test
fun pairsMatchHalvesAndNeverMixBannersReleases() {
val check = TurnipReleases.Check(
listOf(
asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER),
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER),
// The A8xx set's newest display build is r4, but its Linux half is only in r3.
asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX),
asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX),
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED),
),
emptyList(), emptyList(), 0L,
)
val pairs = DriverPairs.from(check).associateBy { it.key }
assertTrue(pairs.getValue(DriverPairs.BANNER).complete)
assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete)
val wn = pairs.getValue(DriverPairs.WN_BALANCED)
assertTrue("WinNative's two release lines pair up", wn.complete)
assertEquals("v1.19 + v0.1.2", wn.version)
assertTrue(wn.suits(gpu(830)))
assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830)))
}
@Test
fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() {
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)
val newer = bundle("DD-Turnip-v0.2.0")
val older = bundle("DD-Turnip-v0.1.0")
val pairs = DriverPairs.from(TurnipReleases.Check(
listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older,
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)),
emptyList(), emptyList(), 0L,
))
assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key })
val dd = pairs.first()
assertTrue(dd.complete)
assertEquals(listOf(newer), dd.assets)
assertEquals("v0.2.0", dd.version)
assertEquals("DroidDeck · DD-Turnip", dd.name)
assertTrue(dd.suits(gpu(840)))
assertTrue(dd.suits(gpu(740)))
}
@Test
fun adreno840GetsTheNewestOfficialDdTurnip() {
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)
val pairs = DriverPairs.from(TurnipReleases.Check(
listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"),
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)),
emptyList(), emptyList(), 0L,
))
assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs))
assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs))
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs))
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs))
}
}