diff --git a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt index 34460cc90..efffe789d 100644 --- a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt @@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val var deckyChecking by mutableStateOf(false) var deckyStage by mutableStateOf(null) var deckyPercent by mutableIntStateOf(-1) + /** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */ + var deckyDownloadingBinary by mutableStateOf(false) var deckySupervisor by mutableStateOf(false) var deckyReleaseRequest = 0 @@ -59,11 +61,12 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val Thread({ val problem = runCatching { DeckyManager.installPluginZip(activity, archive) { label, value -> - ui.post { deckyStage = label; deckyPercent = value } + val binary = DeckyManager.downloadingBinary + ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary } } }.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) } ui.post { - deckyStage = null; deckyPercent = -1 + deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false val message = problem ?: activity.getString(R.string.decky_plugin_installed) android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 6051bca9a..713e86233 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1178,6 +1178,7 @@ class MainActivity : ComponentActivity() { 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, + deckyDownloadingBinary = decky.deckyDownloadingBinary, deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running, ), ModeSettingsActions( diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 7efcc642e..f164c0dcb 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -436,6 +436,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (hud.text.isNotEmpty()) HudText(hud.text) if (loading.visible) LoadingOverlay( loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended, + topic = loading.topic, readable = loading.readable, title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM, // As the drawer's Stop does: the service stops, and a start still installing is cancelled. onCancel = { SessionService.stop(this@SessionActivity); finish() }, @@ -796,7 +797,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { "${SessionState.installing}.installing", mapOf("component" to SessionState.installing), ) - loading.step = getString((if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES).downloading) + val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES + loading.say(getString(first.downloading), first.topic, readable = true) loading.percent = -1 Thread({ var failedComponent: String? = null @@ -837,7 +839,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return@post } loading.percent = -1 - loading.step = getString(R.string.session_starting_session) + loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false) // The surface may have come and gone while the download ran; start on the live one. if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder) } @@ -853,17 +855,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, "session-failure-artifacts").start() } - /** The loading screen's lines for one package: downloading it (with and without its size), checking it, unpacking it. */ - private class PackageLines(val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int) + /** + * The loading screen's lines for one package: downloading it (with and without its size), checking it, + * unpacking it; the checklist [topic] they belong to, and whether the checking line is shown as it is. + */ + private class PackageLines( + val downloading: Int, val downloadingMb: Int, val checking: Int, val unpacking: Int, + val topic: LoadingState.Topic, val checkingReadable: Boolean, + ) /** Reports one package's download on the loading screen: " ยท 332 of 791 MB", checking, unpacking. */ - private fun progressFor(what: PackageLines, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p -> - uiHandler.post { - loading.percent = p - loading.step = when { - stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) getString(what.downloadingMb, p * mb / 100, mb) else getString(what.downloading) - stage.startsWith("Verifying") -> getString(what.checking) - else -> getString(what.unpacking) + private fun progressFor(what: PackageLines, mb: Long) = object : com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { + override fun onProgress(stage: String, percent: Int) = + onProgress(com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.OTHER, stage, percent) + + override fun onProgress(step: com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step, stage: String, percent: Int) { + uiHandler.post { + loading.percent = percent + when (step) { + com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.DOWNLOADING -> loading.say( + if (percent >= 0 && mb > 0) getString(what.downloadingMb, percent * mb / 100, mb) else getString(what.downloading), + what.topic, readable = true, + ) + com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.Step.VERIFYING -> + loading.say(getString(what.checking), what.topic, what.checkingReadable) + else -> loading.say(getString(what.unpacking), what.topic, readable = true) + } } } } @@ -879,7 +896,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** Null when the desktop package is in, else the loading screen's closing line. */ private fun installDesktop(): String? { - uiHandler.post { loading.percent = -1; loading.step = getString(R.string.session_desktop_downloading) } + uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) } val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" } ?: return getString(R.string.session_desktop_catalog_unreachable) val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, @@ -889,7 +906,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** Null when the ARM64 Proton is in, else why not; the session goes on either way. */ private fun installProton(): String? { - uiHandler.post { loading.percent = -1; loading.step = getString(R.string.session_proton_downloading) } + uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) } val catalog = com.droiddeck.launcher.runtime.DesktopCatalog val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID } ?: return "catalog unreachable" @@ -2038,14 +2055,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val RUNTIME_LINES = PackageLines( R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb, R.string.session_runtime_checking, R.string.session_runtime_unpacking, + LoadingState.Topic.RUNTIME, checkingReadable = true, ) private val DESKTOP_LINES = PackageLines( R.string.session_desktop_downloading, R.string.session_desktop_downloading_mb, R.string.session_desktop_checking, R.string.session_desktop_unpacking, + LoadingState.Topic.DESKTOP, checkingReadable = false, ) private val PROTON_LINES = PackageLines( R.string.session_proton_downloading, R.string.session_proton_downloading_mb, R.string.session_proton_checking, R.string.session_proton_unpacking, + LoadingState.Topic.STEAM, checkingReadable = false, ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt index f6229d01c..26799d003 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt @@ -17,9 +17,16 @@ import java.nio.charset.StandardCharsets * progress, and lifting that out is the difference between "starting the Steam client" for three * minutes and a percentage that moves. */ -class LoadingState(context: Context, steam: Boolean = true) { +class LoadingState(context: Context, private val steam: Boolean = true) { var visible by mutableStateOf(true) var step by mutableStateOf(context.getString(com.droiddeck.launcher.R.string.loadstate_starting)) + private set + /** What [step] is about, so the screen picks its checklist stage without reading the (translated) words. */ + var topic by mutableStateOf(Topic.SESSION) + private set + /** [step] is a download or install line worth showing as it is, not one of the script's own notes. */ + var readable by mutableStateOf(false) + private set var percent by mutableIntStateOf(-1) var elapsed by mutableStateOf("") var hint by mutableStateOf("") @@ -41,6 +48,13 @@ class LoadingState(context: Context, steam: Boolean = true) { hint = hints[((seconds / 8) % hints.size).toInt()] } + /** A line the app itself writes (an install's progress, the session starting). */ + fun say(line: String, topic: Topic, readable: Boolean) { + step = line + this.topic = topic + this.readable = readable + } + fun showEnded(message: String, detail: String? = null) { visible = true ended = true @@ -51,12 +65,16 @@ class LoadingState(context: Context, steam: Boolean = true) { /** Re-reads the end of the log and updates the line and the bar. */ fun update(context: Context, log: File?) { val state = read(context, log) ?: return - step = state.first - percent = state.second + step = state.line + topic = state.topic + readable = state.readable + percent = state.percent } + private class Line(val line: String, val percent: Int, val topic: Topic, val readable: Boolean) + /** Only the tail is read: the client alone writes megabytes an hour. */ - private fun read(context: Context, log: File?): Pair? { + private fun read(context: Context, log: File?): Line? { if (log == null || !log.isFile) return null val text = try { RandomAccessFile(log, "r").use { file -> @@ -101,16 +119,45 @@ class LoadingState(context: Context, steam: Boolean = true) { } } return when { - downloadAt > stepAt && downloadPercent >= 0 -> - Pair(context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent) - clientDownloadAt == stepAt && clientDownload != null -> - Pair(context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent) - stepText != null -> Pair(stepText, -1) + downloadAt > stepAt && downloadPercent >= 0 -> Line( + context.getString(com.droiddeck.launcher.R.string.loadstate_steam_update, downloadPercent), downloadPercent, + Topic.STEAM, readable = true, + ) + clientDownloadAt == stepAt && clientDownload != null -> Line( + context.getString(com.droiddeck.launcher.R.string.loadstate_steam_client, clientDownload), clientPercent, + Topic.STEAM, readable = true, + ) + stepText != null -> Line(stepText, -1, topicOf(stepText, steam), readableScriptStep(stepText)) else -> null } } + /** The checklist stages a loading line can move the screen to. */ + enum class Topic { RUNTIME, SESSION, DESKTOP, STEAM, STEAM_STARTING, OTHER } + companion object { + /** + * A session script "== STEP" line's topic in a Steam ([steam]) or desktop session; those + * lines are English whatever the app's language. + */ + internal fun topicOf(scriptStep: String, steam: Boolean): Topic { + val t = scriptStep.lowercase() + return when { + "linux runtime" in t -> Topic.RUNTIME + "starting the session" in t -> Topic.SESSION + !steam && "desktop" in t -> Topic.DESKTOP + steam && "starting the steam client" in t -> Topic.STEAM_STARTING + steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> Topic.STEAM + else -> Topic.OTHER + } + } + + /** The script's install lines are worth reading as they are; its other notes are not. */ + internal fun readableScriptStep(scriptStep: String): Boolean { + val t = scriptStep.lowercase() + return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing") + } + private const val TAIL_BYTES = 48L * 1024 private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""") private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index da74f39ee..fd870c945 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -105,6 +105,8 @@ class ModeSettings( val deckyChecking: Boolean = false, val deckyStage: String? = null, val deckyPercent: Int = -1, + /** The stage is the Decky plugin's binary download. */ + val deckyDownloadingBinary: Boolean = false, val deckyEnabled: Boolean = false, val deckySessionRunning: Boolean = false, ) @@ -339,7 +341,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { stringResource(R.string.mode_decky_plugins_hint), ) { SecondaryButton( - if (s.deckyStage?.startsWith("Downloading plugin binary") == true) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip), + if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip), enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning, onClick = a.onPickDeckyPluginZip, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 5a3bb4e0d..553ab1d3c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.LoadingState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.animation.core.FastOutSlowInEasing @@ -105,31 +106,24 @@ private fun loadStages(steam: Boolean): List = if (steam) listOf( ) /** - * The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines - * are the session script's "== STEP" milestones and the installer's own progress; they arrive in - * order, and the screen never steps back. + * The checklist stage a loading line's [topic] belongs to, -1 when it says nothing about the stage. + * The lines are the session script's "== STEP" milestones and the installer's own progress; they + * arrive in order, and the screen never steps back. */ -private fun stageOf(step: String, steam: Boolean): Int { - val t = step.lowercase() - return when { - "linux runtime" in t -> 0 - "starting the session" in t -> 1 - !steam && "desktop" in t -> 1 - steam && "starting the steam client" in t -> 3 - steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2 - else -> -1 - } -} - -/** Download and install lines are worth reading as they are; the script's own notes are not. */ -private fun readableStep(step: String): Boolean { - val t = step.lowercase() - return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing") +private fun stageOf(topic: LoadingState.Topic, steam: Boolean): Int = when (topic) { + LoadingState.Topic.RUNTIME -> 0 + LoadingState.Topic.SESSION -> 1 + LoadingState.Topic.DESKTOP -> if (steam) -1 else 1 + LoadingState.Topic.STEAM_STARTING -> if (steam) 3 else -1 + LoadingState.Topic.STEAM -> if (steam) 2 else -1 + LoadingState.Topic.OTHER -> -1 } @Composable fun LoadingOverlay( step: String, percent: Int, elapsed: String, hint: String, ended: Boolean, + /** What [step] is about, and whether it is a download or install line worth showing as it is (see [LoadingState]). */ + topic: LoadingState.Topic, readable: Boolean, title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null, /** An ended session's exit status and log path, shown small under the advice. */ endedDetail: String? = null, @@ -144,12 +138,12 @@ fun LoadingOverlay( var ball by remember { mutableStateOf?>(null) } val stages = remember(steam) { loadStages(steam) } var reached by remember { mutableStateOf(0) } - LaunchedEffect(step, steam) { - val at = stageOf(step, steam) + LaunchedEffect(step, topic, steam) { + val at = stageOf(topic, steam) if (at > reached) reached = at } val active = reached.coerceAtMost(stages.lastIndex) - val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail) + val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail) val context = LocalContext.current // The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in. val games by produceState(emptyList()) {