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 2429e6698c0f..35e5ce04f4c6 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 @@ -110,32 +110,65 @@ internal fun StateFlow.resolveOptionalNativeText(): StateFlow nativeString("\${durationMs}ms", durationMs) }, run.deliveryStatus?.let(::cronDeliveryStatusLabel), run.model, run.error ?: run.summary, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt index 4fcf2868311d..a0d7804dd7d2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt @@ -138,26 +138,31 @@ internal fun buildGatewayDiagnosticsReport( .ifEmpty { Build.VERSION.SDK_INT.toString() } val endpoint = gatewayAddress.trim().ifEmpty { "unknown" } val status = statusText.trim().ifEmpty { "Offline" } - return """ - Help diagnose this OpenClaw Android gateway connection failure. - - Please: - - pick one route only: same machine, same LAN, Tailscale, or public URL - - classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down - - remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs - - quote the exact app status/error below - - tell me whether `openclaw devices list` should show a pending pairing request - - if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status` - - give the next exact command or tap - - Debug info: - - screen: $screen - - app version: ${openClawAndroidVersionLabel()} - - device: $device - - android: $androidVersion (SDK ${Build.VERSION.SDK_INT}) - - gateway address: $endpoint - - status/error: $status - """.trimIndent() + return nativeString( + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + + "Please:\n" + + "- pick one route only: same machine, same LAN, Tailscale, or public URL\n" + + "- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down\n" + + "- remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs\n" + + "- quote the exact app status/error below\n" + + "- tell me whether `openclaw devices list` should show a pending pairing request\n" + + "- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`\n" + + "- give the next exact command or tap\n\n" + + "Debug info:\n" + + "- screen: \$screen\n" + + "- app version: \$appVersion\n" + + "- device: \$device\n" + + "- android: \$androidVersion (SDK \$sdkVersion)\n" + + "- gateway address: \$endpoint\n" + + "- status/error: \$status", + screen, + openClawAndroidVersionLabel(), + device, + androidVersion, + Build.VERSION.SDK_INT, + endpoint, + status, + ) } /** Copies the diagnostics report to Android clipboard and shows a short confirmation toast. */ 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 2c4daaed74d3..7979e55f290c 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 @@ -2241,7 +2241,7 @@ private fun CronJobDetailPanel( SettingsMetric("ID", job.id, copyable = true), SettingsMetric(nativeString("Description"), job.description.ifBlank { nativeString("None") }), SettingsMetric(nativeString("Schedule Detail"), job.scheduleDetail.resolveNativeTextResource()), - SettingsMetric(nativeString("Session Target"), job.sessionTarget), + SettingsMetric(nativeString("Session Target"), cronSessionTargetLabel(job.sessionTarget)), SettingsMetric(nativeString("Wake Mode"), cronWakeModeLabel(job.wakeMode)), SettingsMetric(nativeString("Delete After Run"), if (job.deleteAfterRun) nativeString("Yes") else nativeString("No")), SettingsMetric(nativeString("Payload"), job.payloadLabel.resolveNativeTextResource()), @@ -2436,6 +2436,8 @@ internal fun execApprovalMetadata( val nodeId = approval.nodeId.take(8) nativeString("Node \${nodeId}", nodeId) } + approval.host == "node" -> nativeString("Node") + approval.host == "gateway" -> nativeString("Gateway") approval.host != null -> approval.host else -> nativeString("Gateway") } @@ -2463,11 +2465,19 @@ internal fun formatApprovalDuration(deltaMs: Long): String { val hours = minutes / 60L return when { minutes < 1 -> nativeString("soon") - hours < 1 -> "${minutes}m" - else -> "${hours}h" + hours < 1 -> nativeString("\${minutes}m", minutes) + else -> nativeString("\${hours}h", hours) } } +internal fun cronSessionTargetLabel(target: String): String = + when (target) { + "main" -> nativeString("Main") + "isolated" -> nativeString("Isolated") + "current" -> nativeString("Current") + else -> target + } + /** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */ private fun cronJobSubtitle(job: GatewayCronJobSummary): String = nativeString( @@ -2504,9 +2514,12 @@ internal fun formatUsageUpdated( val hours = minutes / 60L return when { minutes < 1 -> nativeString("Now") - hours < 1 -> "${minutes}m" - hours < 24 -> "${hours}h" - else -> "${hours / 24L}d" + hours < 1 -> nativeString("\${minutes}m", minutes) + hours < 24 -> nativeString("\${hours}h", hours) + else -> { + val days = hours / 24L + nativeString("\${days}d", days) + } } } @@ -2619,9 +2632,9 @@ internal fun formatCronWake( val hours = minutes / 60L val days = hours / 24L return when { - days > 0 -> "${days}d" - hours > 0 -> "${hours}h" - minutes > 0 -> "${minutes}m" + days > 0 -> nativeString("\${days}d", days) + hours > 0 -> nativeString("\${hours}h", hours) + minutes > 0 -> nativeString("\${minutes}m", minutes) else -> nativeString("Soon") } } 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 4a9766bdd269..a3a489297067 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 @@ -165,6 +165,9 @@ class AppLanguageTest { "Impossible de charger les approbations.", gatewayExecApprovalTextForDisplay("Could not load approvals."), ) + assertEquals("1 min", formatApprovalDuration(60_000)) + assertEquals("2 min", formatUsageUpdated(updatedAtMs = 0, nowMs = 120_000)) + assertEquals("3 h", formatCronWake(timeMs = 10_800_000, nowMs = 0)) } finally { setAppLanguage(previous) activity.destroy() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt index 3b58cf70ea6c..ea7a64d9a968 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt @@ -1,6 +1,7 @@ package ai.openclaw.app import ai.openclaw.app.i18n.NativeStringResources +import ai.openclaw.app.i18n.nativeString import android.app.Notification import android.app.NotificationManager import android.app.Service @@ -184,12 +185,17 @@ class NodeForegroundServiceTest { writer.write("""""") } NativeStringResources.install(app) + assertEquals("Démarrage…", nativeString("Starting…")) + val manager = app.getSystemService(NotificationManager::class.java) + manager.cancelAll() val controller = Robolectric.buildService(NodeForegroundService::class.java) try { controller.create() - val manager = app.getSystemService(NotificationManager::class.java) + assertSame(app, controller.get().application) + assertTrue(app.getFileStreamPath(localesFile).exists()) + assertEquals("Démarrage…", nativeString("Starting…")) val notification = Shadows.shadowOf(manager).getNotification(1) assertEquals("Démarrage…", notification.extras.getCharSequence(Notification.EXTRA_TEXT)) } finally { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt index 7491a61fbf8c..8dfd81b51a56 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt @@ -44,6 +44,11 @@ class NativeStringsTest { NativeStringResources.setConfigurationLocales(configuration) assertEquals("Micro désactivé", nativeString("Mic off")) + + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("de")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Mikrofon aus", nativeString("Mic off")) } @Test @@ -59,6 +64,23 @@ class NativeStringsTest { assertEquals("Mic off", nativeString("Mic off")) } + @Test + fun configurationLocaleDoesNotReplacePersistedAppLocale() { + val app = RuntimeEnvironment.getApplication() + persistAppLocales(app, "fr") + try { + NativeStringResources.install(app) + + val configuration = Configuration(app.resources.configuration) + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("en")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Micro désactivé", nativeString("Mic off")) + } finally { + app.deleteFile(APP_LOCALES_FILE) + } + } + @Test fun resolvingStateFlowEmitsWhenThePersistedAppLocaleChanges() = runBlocking { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index 830a010506b2..acef1a718e29 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -237,10 +237,32 @@ class SettingsScreensTest { nowMs = 0, ), ) + assertEquals( + "Node", + execApprovalMetadata( + approval.copy(host = "node", nodeId = null, agentId = null, createdAtMs = null, expiresAtMs = null), + nowMs = 0, + ), + ) + assertEquals( + "Gateway", + execApprovalMetadata( + approval.copy(host = "gateway", nodeId = null, agentId = null, createdAtMs = null, expiresAtMs = null), + nowMs = 0, + ), + ) assertEquals("soon", formatApprovalDuration(0)) assertEquals("Action Request", approvalActionName("")) } + @Test + fun cronSessionTargetsLocalizeClosedCodesAndPreserveCustomTargets() { + assertEquals("Main", cronSessionTargetLabel("main")) + assertEquals("Isolated", cronSessionTargetLabel("isolated")) + assertEquals("Current", cronSessionTargetLabel("current")) + assertEquals("session:custom", cronSessionTargetLabel("session:custom")) + } + @Test fun usageAndCronSummariesLocalizeOnlyControlledWords() { val provider =