fix(android): preserve locale and localize closed presentation

This commit is contained in:
Vincent Koc
2026-07-12 12:39:21 +02:00
parent f6fd4f4db5
commit f513eef4be
8 changed files with 143 additions and 38 deletions

View File

@@ -110,32 +110,65 @@ internal fun StateFlow<NativeText?>.resolveOptionalNativeText(): StateFlow<Strin
@SuppressLint("StaticFieldLeak")
internal object NativeStringResources {
private sealed interface ApplicationLocaleMode {
val locales: LocaleListCompat
data class Pinned(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
data class System(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
}
@Volatile
private var applicationContext: Context? = null
@Volatile
private var applicationLocales: LocaleListCompat? = null
private var applicationLocaleMode: ApplicationLocaleMode? = null
@Volatile
private var localizedContext: Context? = null
@Synchronized
fun install(context: Context) {
applicationContext = context.applicationContext
applicationLocales = null
val appContext = context.applicationContext
applicationContext = appContext
val requestedLocales =
LocaleManagerCompat
.getApplicationLocales(appContext)
.takeUnless { it.isEmpty }
?: appContext.readStoredAppLocales()
applicationLocaleMode =
if (requestedLocales.isEmpty) {
ApplicationLocaleMode.System(ConfigurationCompat.getLocales(appContext.resources.configuration))
} else {
ApplicationLocaleMode.Pinned(requestedLocales)
}
localizedContext = null
}
@Synchronized
fun setApplicationLocales(locales: LocaleListCompat) {
applicationLocales = locales
applicationLocaleMode =
if (locales.isEmpty) {
val context = applicationContext
ApplicationLocaleMode.System(
context?.let { ConfigurationCompat.getLocales(it.resources.configuration) }
?: LocaleListCompat.getEmptyLocaleList(),
)
} else {
ApplicationLocaleMode.Pinned(locales)
}
localizedContext = null
}
@Synchronized
fun setConfigurationLocales(configuration: Configuration) {
if (applicationLocales?.isEmpty == false) return
applicationLocales = ConfigurationCompat.getLocales(configuration)
if (applicationLocaleMode is ApplicationLocaleMode.Pinned) return
applicationLocaleMode =
ApplicationLocaleMode.System(ConfigurationCompat.getLocales(configuration))
localizedContext = null
}
@@ -155,7 +188,8 @@ internal object NativeStringResources {
localizedContext
?: context
.localizedContext(
applicationLocales
applicationLocaleMode
?.locales
?: LocaleManagerCompat
.getApplicationLocales(context)
.takeUnless { it.isEmpty }

View File

@@ -644,7 +644,7 @@ internal fun cronWakeModeLabel(code: String): String = cronWakeModeOptions().fir
internal fun cronRunSubtitle(run: GatewayCronRunSummary): String =
listOfNotNull(
run.durationMs?.let { "${it}ms" },
run.durationMs?.let { durationMs -> nativeString("\${durationMs}ms", durationMs) },
run.deliveryStatus?.let(::cronDeliveryStatusLabel),
run.model,
run.error ?: run.summary,

View File

@@ -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. */

View File

@@ -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")
}
}

View File

@@ -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()

View File

@@ -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("""<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><locales application_locales="fr" />""")
}
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 {

View File

@@ -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 {

View File

@@ -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 =