mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 22:51:37 +00:00
fix(android): preserve locale and localize closed presentation
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user