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
This commit is contained in:
maxjivi05 committed 2026-10-04 22:44:10 -04:00
1 parent 51e3423f58
commit aece5b9bcb
6 files changed
+114 -47

No files matched your search

@@ -19,6 +19,8 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(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()
}
@@ -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(
@@ -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: "<what> · 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,
)
}
}
@@ -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<String, Int>? {
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+)\)""")
@@ -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,
)
@@ -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<LoadStage> = 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<Pair<Offset, Float>?>(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<Library.SteamGame>()) {