diff --git a/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt b/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt index 7b3deb1c9af4..911432254116 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt @@ -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?, 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? = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 9cc92e6318c5..caa33651792e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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.")) } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt index bb7c1e76b82b..c7b7aed4b29c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt @@ -25,6 +25,11 @@ sealed interface NativeText { data class Verbatim( val value: String, ) : NativeText + + data class Composite( + val parts: List, + 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.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() + 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() + 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 } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 4024312a673f..5a508baa71b7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -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)), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt index b5be916ece4b..1175b2c58802 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt @@ -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.", diff --git a/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt index a76772c28c4a..f26ec1455191 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt @@ -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) }