diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 83e0468f0..7083dcc36 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -91,7 +91,7 @@ class FrontEndActivity : ComponentActivity() { private val components = ComponentsMenu(this, ui) private val decky = DeckyMenu(this, ui) private val updates = UpdatesMenu(this, ui) - private val protons = ProtonMenu(this, ui) + private val protons = ProtonMenu(this, ui) { !busy && pkgStage == null && components.compBusy == null } private var installed by mutableStateOf(null) private var flood by mutableStateOf(null) diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt index 5bd651cf5..6bb28bc6c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher import android.os.Handler +import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -8,31 +10,47 @@ import androidx.compose.runtime.setValue import com.droiddeck.launcher.session.ProtonExtras import com.droiddeck.launcher.ui.ProtonRow import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.frontend.AutoComponents +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller -internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { +internal class ProtonMenu( + private val activity: android.app.Activity, + private val ui: Handler, + private val idle: () -> Boolean = { true }, +) { var protonRows by mutableStateOf>(emptyList()) var protonBusyId by mutableStateOf(null) var protonStage by mutableStateOf(null) var protonPercent by mutableIntStateOf(-1) private val checking = java.util.concurrent.atomic.AtomicBoolean(false) + private val autoAttempts = HashMap>() + @VisibleForTesting internal var installer: (ProtonExtras.Tool, (String, Int) -> Unit) -> String? = + { tool, progress -> ProtonExtras.install(activity, tool, progress) } - fun installProton(id: String) { + fun installProton(id: String, automatic: Boolean = false) { val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return - if (protonBusyId != null || SessionState.running) return + if (protonBusyId != null || ProtonExtras.installInProgress || SessionState.running) return ProtonExtras.unqueue(activity, tool) protonBusyId = id protonStage = activity.getString(R.string.store_starting) protonPercent = -1 Thread({ - val problem = ProtonExtras.install(activity, tool) { label, value -> - ui.post { protonStage = label; protonPercent = value } - } + val problem = runCatching { + installer(tool) { label, value -> + ui.post { protonStage = label; protonPercent = value } + } + }.getOrElse { it.message ?: it.javaClass.simpleName } ui.post { protonBusyId = null protonStage = null protonPercent = -1 refreshProtons() - if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show() + if (problem != null) { + if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem") + else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show() + } + if (automatic) ensureAutoUpdates() } }, "install-proton-$id").start() } @@ -64,7 +82,23 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va if (!checking.compareAndSet(false, true)) return Thread({ val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) } - if (asked) ui.post { refreshProtons() } + ui.post { + if (asked) refreshProtons() + ensureAutoUpdates() + } }, "proton-updates").start() } + + internal fun ensureAutoUpdates(now: Long = System.currentTimeMillis()) { + if (activity.isFinishing || activity.isDestroyed || !idle() || protonBusyId != null || + ProtonExtras.installInProgress || SessionState.running || LinuxRuntimeInstaller.isBusy() || + !LinuxRuntime.isInstalled(activity) || !AutoComponents.online(activity) || AutoComponents.unfavourable(activity)) return + val (tool, update) = ProtonExtras.tools.firstNotNullOfOrNull { tool -> + val update = ProtonExtras.update(activity, tool) ?: return@firstNotNullOfOrNull null + val previous = autoAttempts[tool.id] + if (previous == null || previous.first != update || now - previous.second !in 0 until 15L * 60 * 1000) tool to update else null + } ?: return + autoAttempts[tool.id] = update to now + installProton(tool.id, automatic = true) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt new file mode 100644 index 000000000..124fd4e66 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt @@ -0,0 +1,144 @@ +package com.droiddeck.launcher + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import com.droiddeck.launcher.frontend.AutoComponents +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.ProtonExtras +import com.droiddeck.launcher.session.SessionState +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ProtonMenuAutoTest { + private val online = AutoComponents.online + private val constrained = AutoComponents.unfavourable + private lateinit var activity: Activity + private lateinit var menu: ProtonMenu + private lateinit var root: File + private val attempts = mutableListOf() + private var idle = true + + @Before fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + root = LinuxRuntime.rootDir(activity) + for (path in listOf("usr/bin/gamescope", "usr/local/bin/droiddeck-session", "opt/android-host/proot", "opt/android-host/loader")) { + File(root, path).apply { parentFile!!.mkdirs(); writeText("") } + } + AutoComponents.online = { true } + AutoComponents.unfavourable = { false } + SessionState.running = false + val latest = JSONObject() + for (tool in ProtonExtras.tools) latest.put(tool.id, JSONObject() + .put("tag", "${tool.prefix}-11-12").put("at", System.currentTimeMillis())) + File(activity.filesDir, "proton-latest.json").writeText(latest.toString()) + menu = ProtonMenu(activity, Handler(Looper.getMainLooper())) { idle } + menu.installer = { tool, progress -> + progress("Installing ${tool.name}", 50) + install("${tool.prefix}-11-12") + null + } + } + + @After fun reset() { + AutoComponents.online = online + AutoComponents.unfavourable = constrained + SessionState.running = false + } + + private fun install(name: String): File = File(root, "root/.local/share/Steam/compatibilitytools.d/$name").apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("manifest") + } + + private fun waitUntil(done: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (done()) return + Thread.sleep(10) + } + fail("Proton work did not finish") + } + + @Test fun aCachedCheckUpdatesOnlyInstalledFamiliesAndKeepsOlderBuildsAndSelections() { + val older = install("DroidDeck-Proton-11-11.1") + val request = File(root, "root/.local/share/droiddeck-compat/request.json").apply { + parentFile!!.mkdirs() + writeText("{\"seq\":1,\"dir\":\"DroidDeck-Proton-11-11.1\",\"valve\":false}") + } + val before = request.readText() + assertFalse(ProtonExtras.checkLatest(activity)) + menu.checkUpdates() + waitUntil { menu.protonRows.singleOrNull { it.id == "droiddeck" }?.installed == "DroidDeck-Proton-11-12" && menu.protonBusyId == null } + assertTrue(older.isDirectory) + assertEquals(before, request.readText()) + assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "ge" }).isEmpty()) + assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "cachyos" }).isEmpty()) + } + + @Test fun downloadsWaitForTheSessionNetworkPowerAndOtherInstallWork() { + install("DroidDeck-Proton-11-11.1") + menu.installer = { tool, _ -> attempts.add(tool.id); "failed" } + SessionState.running = true + menu.ensureAutoUpdates() + SessionState.running = false + AutoComponents.online = { false } + menu.ensureAutoUpdates() + AutoComponents.online = { true } + AutoComponents.unfavourable = { true } + menu.ensureAutoUpdates() + AutoComponents.unfavourable = { false } + idle = false + menu.ensureAutoUpdates() + assertNull(menu.protonBusyId) + assertTrue(attempts.isEmpty()) + idle = true + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + assertEquals(listOf("droiddeck"), attempts) + } + + @Test fun aFailedUpdateDoesNotLoopOrBlockOtherInstalledFamilies() { + val ge = install("GE-Proton11-11") + val dd = install("DroidDeck-Proton-11-11.1") + menu.installer = { tool, _ -> + attempts.add(tool.id) + if (tool.id == "ge") throw IllegalStateException("download failed") + install("${tool.prefix}-11-12") + null + } + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + menu.ensureAutoUpdates() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(listOf("ge", "droiddeck"), attempts) + assertTrue(ge.isDirectory) + assertTrue(dd.isDirectory) + assertNull(menu.protonStage) + assertEquals(-1, menu.protonPercent) + } + + @Test fun anUninstalledRuntimeOrCurrentProtonDoesNotStartADownload() { + install("DroidDeck-Proton-11-12") + menu.installer = { tool, _ -> attempts.add(tool.id); null } + menu.ensureAutoUpdates() + assertTrue(attempts.isEmpty()) + install("GE-Proton11-11") + File(root, "usr/bin/gamescope").delete() + menu.ensureAutoUpdates() + assertTrue(attempts.isEmpty()) + assertNull(menu.protonBusyId) + } +}