From aece5b9bcb7bb2b41985a74652f93549eca4e022 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Sun, 4 Oct 2026 22:44:10 -0400 Subject: [PATCH] l10n: drive loading and install stages from step IDs, not their text The loading screen's checklist, the session's package lines and the Decky plugin button branched on the English wording of progress lines that are now translated. They now carry what the line is about beside it: the installer's Step, a LoadingState topic and readable flag (the session script's English "== STEP" lines are still classified by their text), and DeckyManager's downloadingBinary. English behaviour is unchanged. Refs #212 --- .../java/com/droiddeck/launcher/DeckyMenu.kt | 7 +- .../com/droiddeck/launcher/MainActivity.kt | 1 + .../com/droiddeck/launcher/SessionActivity.kt | 46 +++++++++---- .../launcher/session/LoadingState.kt | 65 ++++++++++++++++--- .../launcher/ui/ModeSettingsDialog.kt | 4 +- .../droiddeck/launcher/ui/SessionLoading.kt | 38 +++++------ 6 files changed, 114 insertions(+), 47 deletions(-) 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()) {