fix(android): keep cron detail localization reactive

This commit is contained in:
Vincent Koc
2026-07-12 10:46:27 +02:00
parent 1b87391658
commit e5932778f4
6 changed files with 123 additions and 70 deletions

View File

@@ -1,6 +1,9 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
@@ -17,8 +20,8 @@ data class GatewayCronJobDetail(
val enabled: Boolean,
val deleteAfterRun: Boolean,
val scheduleKind: String,
val scheduleLabel: String,
val scheduleDetail: String,
val scheduleLabel: NativeText,
val scheduleDetail: NativeText,
val scheduleAt: String?,
val scheduleEveryMs: Long?,
val scheduleAnchorMs: Long?,
@@ -31,13 +34,13 @@ data class GatewayCronJobDetail(
val wakeMode: String,
val payloadKind: String,
val payloadText: String?,
val payloadLabel: String,
val payloadLabel: NativeText,
val payloadModel: String?,
val payloadThinking: String?,
val payloadCommandArgv: List<String>?,
val payloadCommandCwd: String?,
val deliveryLabel: String,
val failureAlertLabel: String,
val deliveryLabel: NativeText,
val failureAlertLabel: NativeText,
val createdAtMs: Long,
val updatedAtMs: Long,
val configRevision: String?,
@@ -67,7 +70,7 @@ sealed interface GatewayCronJobDetailState {
data class Error(
val id: String,
val message: String,
val message: NativeText,
) : GatewayCronJobDetailState
}
@@ -204,41 +207,41 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail?
)
}
internal fun formatCronInterval(everyMs: Long): String {
internal fun formatCronInterval(everyMs: Long): NativeText {
val minutes = everyMs / 60_000L
val hours = minutes / 60L
val days = hours / 24L
return when {
days >= 1 && hours % 24L == 0L -> nativeString("Every \${days}d", days)
hours >= 1 && minutes % 60L == 0L -> nativeString("Every \${hours}h", hours)
minutes >= 1 -> nativeString("Every \${minutes}m", minutes)
else -> nativeString("Repeating")
days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days)
hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours)
minutes >= 1 -> nativeText("Every \${minutes}m", minutes)
else -> nativeText("Repeating")
}
}
private fun cronScheduleLabel(schedule: JsonObject): String =
private fun cronScheduleLabel(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> nativeString("One time")
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeString("Repeating")
"cron" -> schedule.string("expr") ?: nativeString("Cron")
else -> nativeString("Scheduled")
"at" -> nativeText("One time")
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
"cron" -> schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
else -> nativeText("Scheduled")
}
private fun cronScheduleDetail(schedule: JsonObject): String =
private fun cronScheduleDetail(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> schedule.string("at") ?: nativeString("One time")
"at" -> schedule.string("at")?.let(::verbatimText) ?: nativeText("One time")
"every" -> {
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeString("Repeating")
val anchor = schedule.long("anchorMs")?.let { nativeString("Anchor \$it", it) }
listOfNotNull(every, anchor).joinToString(" · ")
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
val anchor = schedule.long("anchorMs")?.let { nativeText("Anchor \$it", it) }
joinedNativeText(" · ", listOfNotNull(every, anchor))
}
"cron" -> {
val expression = schedule.string("expr") ?: nativeString("Cron")
val timezone = schedule.string("tz")
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeString("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) }
listOfNotNull(expression, timezone, stagger).joinToString(" · ")
val expression = schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
val timezone = schedule.string("tz")?.let(::verbatimText)
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeText("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) }
joinedNativeText(" · ", listOfNotNull(expression, timezone, stagger))
}
else -> nativeString("Scheduled")
else -> nativeText("Scheduled")
}
private fun cronPayloadText(payload: JsonObject): String? =
@@ -252,40 +255,44 @@ private fun cronPayloadText(payload: JsonObject): String? =
else -> null
}
private fun cronPayloadLabel(payload: JsonObject): String =
private fun cronPayloadLabel(payload: JsonObject): NativeText =
when (payload.string("kind")) {
"systemEvent" -> nativeString("System event")
"systemEvent" -> nativeText("System event")
"agentTurn" -> {
val model = payload.string("model")
val thinking = payload.string("thinking")?.let { nativeString("Thinking \$it", it) }
listOfNotNull(nativeString("Agent turn"), model, thinking).joinToString(" · ")
val model = payload.string("model")?.let(::verbatimText)
val thinking = payload.string("thinking")?.let { nativeText("Thinking \$it", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Agent turn"), model, thinking))
}
"command" -> nativeString("Command")
else -> nativeString("Payload")
"command" -> nativeText("Command")
else -> nativeText("Payload")
}
private fun cronDeliveryLabel(delivery: JsonObject?): String {
val value = delivery ?: return nativeString("Default")
val mode = value.string("mode") ?: return nativeString("Default")
return listOfNotNull(
mode.replaceFirstChar { it.uppercaseChar() },
value.string("channel"),
value.string("to"),
value.string("accountId")?.let { nativeString("Account \$it", it) },
).joinToString(" · ")
private fun cronDeliveryLabel(delivery: JsonObject?): NativeText {
val value = delivery ?: return nativeText("Default")
val mode = value.string("mode") ?: return nativeText("Default")
return joinedNativeText(
" · ",
listOfNotNull(
verbatimText(mode.replaceFirstChar { it.uppercaseChar() }),
value.string("channel")?.let(::verbatimText),
value.string("to")?.let(::verbatimText),
value.string("accountId")?.let { nativeText("Account \$it", it) },
),
)
}
private fun cronFailureAlertLabel(failureAlert: JsonElement?): String {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeString("Off")
val alert = failureAlert.asObjectOrNull() ?: return nativeString("Default")
return listOfNotNull(
alert.long("after")?.let { nativeString("After \$it", it) },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() },
alert.string("channel"),
alert.string("to"),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeString("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) },
).joinToString(" · ")
.ifBlank { nativeString("On") }
private fun cronFailureAlertLabel(failureAlert: JsonElement?): NativeText {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeText("Off")
val alert = failureAlert.asObjectOrNull() ?: return nativeText("Default")
val parts =
listOfNotNull(
alert.long("after")?.let { nativeText("After \$it", it) },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() }?.let(::verbatimText),
alert.string("channel")?.let(::verbatimText),
alert.string("to")?.let(::verbatimText),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeText("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) },
)
return if (parts.isEmpty()) nativeText("On") else joinedNativeText(" · ", parts)
}
private fun JsonObject.string(key: String): String? =

View File

@@ -2121,7 +2121,7 @@ class NodeRuntime private constructor(
cronJobDetailRequestGuard.publishIfCurrent(detailRequest) {
_cronJobDetailState.value =
detail?.let(GatewayCronJobDetailState::Loaded)
?: GatewayCronJobDetailState.Error(detailRequest.id, "Gateway returned an invalid cron job.")
?: GatewayCronJobDetailState.Error(detailRequest.id, nativeText("Gateway returned an invalid cron job."))
}
publishScreenshotCronHistory(historyRequest)
}
@@ -4211,7 +4211,7 @@ class NodeRuntime private constructor(
val gatewayScope = captureGatewayDataScope() ?: return
if (!operatorConnected) {
cronJobDetailRequestGuard.publishIfCurrent(request) {
_cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeString("Connect the gateway to inspect cron jobs."))
_cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeText("Connect the gateway to inspect cron jobs."))
}
return
}
@@ -4221,11 +4221,11 @@ class NodeRuntime private constructor(
cronJobDetailRequestGuard.publishIfCurrent(request) {
_cronJobDetailState.value =
parseGatewayCronJobDetail(root)?.let(GatewayCronJobDetailState::Loaded)
?: GatewayCronJobDetailState.Error(request.id, nativeString("Gateway returned an invalid cron job."))
?: GatewayCronJobDetailState.Error(request.id, nativeText("Gateway returned an invalid cron job."))
}
} catch (_: Throwable) {
cronJobDetailRequestGuard.publishIfCurrent(request) {
_cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeString("Could not load cron job."))
_cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeText("Could not load cron job."))
}
}
}

View File

@@ -25,6 +25,11 @@ sealed interface NativeText {
data class Verbatim(
val value: String,
) : NativeText
data class Composite(
val parts: List<NativeText>,
val separator: String,
) : NativeText
}
private val nativeLocaleRevision = MutableStateFlow(0L)
@@ -37,12 +42,40 @@ internal fun nativeText(
internal fun verbatimText(value: String): NativeText = NativeText.Verbatim(value)
internal fun joinedNativeText(
separator: String,
parts: List<NativeText>,
): NativeText = NativeText.Composite(parts = parts, separator = separator)
internal fun NativeText.resolveNativeText(): String =
when (this) {
is NativeText.Resource -> nativeString(source, *formatArgs.toTypedArray())
is NativeText.Resource -> nativeString(source, *formatArgs.map(::resolveNativeFormatArg).toTypedArray())
is NativeText.Verbatim -> value
is NativeText.Composite -> parts.joinToString(separator, transform = NativeText::resolveNativeText)
}
@Composable
internal fun NativeText.resolveNativeTextResource(): String =
when (this) {
is NativeText.Resource -> {
val resolvedArgs = mutableListOf<Any>()
for (formatArg in formatArgs) {
resolvedArgs += if (formatArg is NativeText) formatArg.resolveNativeTextResource() else formatArg
}
nativeStringResource(source, *resolvedArgs.toTypedArray())
}
is NativeText.Verbatim -> value
is NativeText.Composite -> {
val resolvedParts = mutableListOf<String>()
for (part in parts) {
resolvedParts += part.resolveNativeTextResource()
}
resolvedParts.joinToString(separator)
}
}
private fun resolveNativeFormatArg(value: Any): Any = if (value is NativeText) value.resolveNativeText() else value
internal fun notifyNativeLocaleChanged() {
nativeLocaleRevision.update { it + 1 }
}

View File

@@ -34,6 +34,7 @@ import ai.openclaw.app.gatewayTalkSetupStatusText
import ai.openclaw.app.hasPhotoReadPermission
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.i18n.resolveNativeTextResource
import ai.openclaw.app.isReady
import ai.openclaw.app.loadAndroidLicenseNotices
import ai.openclaw.app.locationModeAfterBackgroundSettings
@@ -456,7 +457,7 @@ private fun CronJobDetailSettingsScreen(
}
errorText != null ->
ClawPanel {
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
Text(text = errorText.resolveNativeTextResource(), style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
current == null ->
ClawPanel {
@@ -2221,7 +2222,7 @@ private fun CronJobDetailPanel(
rows =
listOf(
SettingsMetric(nativeString("Status"), if (job.enabled) nativeString("Enabled") else nativeString("Off")),
SettingsMetric(nativeString("Schedule"), job.scheduleLabel),
SettingsMetric(nativeString("Schedule"), job.scheduleLabel.resolveNativeTextResource()),
SettingsMetric(nativeString("Next Wake"), formatCronWake(job.nextRunAtMs)),
SettingsMetric(nativeString("Last Run"), formatCronTimestamp(job.lastRunAtMs)),
),
@@ -2231,13 +2232,13 @@ private fun CronJobDetailPanel(
listOf(
SettingsMetric("ID", job.id, copyable = true),
SettingsMetric(nativeString("Description"), job.description.ifBlank { nativeString("None") }),
SettingsMetric(nativeString("Schedule Detail"), job.scheduleDetail),
SettingsMetric(nativeString("Schedule Detail"), job.scheduleDetail.resolveNativeTextResource()),
SettingsMetric(nativeString("Session Target"), job.sessionTarget),
SettingsMetric(nativeString("Wake Mode"), job.wakeMode),
SettingsMetric(nativeString("Delete After Run"), if (job.deleteAfterRun) nativeString("Yes") else nativeString("No")),
SettingsMetric(nativeString("Payload"), job.payloadLabel),
SettingsMetric(nativeString("Delivery"), job.deliveryLabel),
SettingsMetric(nativeString("Failure Alert"), job.failureAlertLabel),
SettingsMetric(nativeString("Payload"), job.payloadLabel.resolveNativeTextResource()),
SettingsMetric(nativeString("Delivery"), job.deliveryLabel.resolveNativeTextResource()),
SettingsMetric(nativeString("Failure Alert"), job.failureAlertLabel.resolveNativeTextResource()),
SettingsMetric(nativeString("Created"), formatCronTimestamp(job.createdAtMs)),
SettingsMetric(nativeString("Updated"), formatCronTimestamp(job.updatedAtMs)),
SettingsMetric(nativeString("Running Since"), formatCronTimestamp(job.runningAtMs)),

View File

@@ -1,8 +1,10 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.i18n.verbatimText
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
@@ -119,13 +121,22 @@ class AppLanguageTest {
val activity = Robolectric.buildActivity(LocaleTestActivity::class.java).setup()
NativeStringResources.install(activity.get())
val retained = MutableStateFlow(nativeText("Mic off")).resolveNativeText()
val retainedComposite =
MutableStateFlow(
joinedNativeText(
separator = " · ",
parts = listOf(nativeText("Mic off"), verbatimText("raw")),
),
).resolveNativeText()
val previous = currentAppLanguage()
try {
setAppLanguage(AppLanguage.English)
assertEquals("Mic off", retained.value)
assertEquals("Mic off · raw", retainedComposite.value)
setAppLanguage(AppLanguage.French)
assertEquals("Micro désactivé", retained.value)
assertEquals("Micro désactivé · raw", retainedComposite.value)
assertEquals("Connexion…", gatewayConnectionStatusForDisplay("Connecting…"))
assertEquals(
"Impossible de charger les approbations.",

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.resolveNativeText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
@@ -20,17 +21,17 @@ class CronJobDetailTest {
assertEquals("Daily report", detail.name)
assertEquals("sha256:fixture", detail.configRevision)
assertEquals("cron", detail.scheduleKind)
assertEquals("0 9 * * *", detail.scheduleLabel)
assertEquals("0 9 * * * · Europe/Vienna · Stagger Every 5m", detail.scheduleDetail)
assertEquals("0 9 * * *", detail.scheduleLabel.resolveNativeText())
assertEquals("0 9 * * * · Europe/Vienna · Stagger Every 5m", detail.scheduleDetail.resolveNativeText())
assertEquals("0 9 * * *", detail.scheduleCronExpr)
assertEquals("Europe/Vienna", detail.scheduleTimezone)
assertEquals(300000L, detail.scheduleStaggerMs)
assertEquals("Agent turn · openai/gpt-5.5 · Thinking high", detail.payloadLabel)
assertEquals("Agent turn · openai/gpt-5.5 · Thinking high", detail.payloadLabel.resolveNativeText())
assertEquals("Summarize the day", detail.payloadText)
assertEquals("openai/gpt-5.5", detail.payloadModel)
assertEquals("high", detail.payloadThinking)
assertEquals("Announce · telegram · chat-42 · Account primary", detail.deliveryLabel)
assertEquals("After 3 · Announce · telegram · ops · Cooldown Every 1h", detail.failureAlertLabel)
assertEquals("Announce · telegram · chat-42 · Account primary", detail.deliveryLabel.resolveNativeText())
assertEquals("After 3 · Announce · telegram · ops · Cooldown Every 1h", detail.failureAlertLabel.resolveNativeText())
assertEquals(2L, detail.consecutiveErrors)
assertEquals("error", detail.lastRunStatus)
}