feat(android): generate native localization resources

This commit is contained in:
Vincent Koc
2026-07-12 13:26:39 +02:00
committed by Vincent Koc
parent e864155d74
commit c03d64558f
48 changed files with 24965 additions and 43 deletions
@@ -4,6 +4,7 @@ import ai.openclaw.app.chat.ChatCacheDatabase
import ai.openclaw.app.chat.RoomChatCommandOutbox
import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.i18n.NativeStringResources
import android.app.Application
import android.os.StrictMode
import androidx.room.withTransaction
@@ -102,6 +103,7 @@ class NodeApp : Application() {
override fun onCreate() {
super.onCreate()
NativeStringResources.install(this)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
package ai.openclaw.app.i18n
import android.content.Context
import android.content.res.Configuration
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
internal object NativeStringResources {
@Volatile
private var applicationContext: Context? = null
@Volatile
private var localizedContext: Context? = null
@Volatile
private var localizedTags: String? = null
@Synchronized
fun install(context: Context) {
applicationContext = context.applicationContext
localizedContext = null
localizedTags = null
}
fun resolve(
source: String,
vararg formatArgs: Any,
): String {
val context = applicationContext ?: return source
val appLocales = AppCompatDelegate.getApplicationLocales()
val tags = appLocales.toLanguageTags()
val cached = localizedContext
val localized =
if (cached != null && localizedTags == tags) {
cached
} else {
synchronized(this) {
if (localizedContext == null || localizedTags != tags) {
localizedContext = context.localizedContext(appLocales)
localizedTags = tags
}
localizedContext ?: context
}
}
return localized.nativeString(source, *formatArgs)
}
}
private fun Context.localizedContext(appLocales: androidx.core.os.LocaleListCompat): Context {
if (appLocales.isEmpty) return this
val locales = Array(appLocales.size()) { index -> checkNotNull(appLocales[index]) }
val configuration = Configuration(resources.configuration).apply { setLocales(LocaleList(*locales)) }
return createConfigurationContext(configuration)
}
internal fun nativeString(
source: String,
vararg formatArgs: Any,
): String = NativeStringResources.resolve(source, *formatArgs)
@Composable
internal fun nativeStringResource(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return source
return if (formatArgs.isEmpty()) stringResource(resourceId) else stringResource(resourceId, *formatArgs)
}
internal fun Context.nativeString(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return source
return if (formatArgs.isEmpty()) getString(resourceId) else getString(resourceId, *formatArgs)
}
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"اسأل OpenClaw %1$s"</item>
<item>"أخبر OpenClaw أن %1$s"</item>
<item>"افتح OpenClaw واسأل %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"OpenClaw %1$s fragen"</item>
<item>"OpenClaw anweisen, %1$s"</item>
<item>"OpenClaw öffnen und %1$s fragen"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"preguntar a OpenClaw %1$s"</item>
<item>"decirle a OpenClaw que %1$s"</item>
<item>"abrir OpenClaw y preguntar %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"از OpenClaw بپرس %1$s"</item>
<item>"به OpenClaw بگو %1$s"</item>
<item>"OpenClaw را باز کن و بپرس %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"demander à OpenClaw %1$s"</item>
<item>"dire à OpenClaw de %1$s"</item>
<item>"ouvrir OpenClaw et demander %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"OpenClaw से %1$s पूछें"</item>
<item>"OpenClaw को %1$s बताने के लिए कहें"</item>
<item>"OpenClaw खोलें और %1$s पूछें"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"tanyakan OpenClaw %1$s"</item>
<item>"beri tahu OpenClaw untuk %1$s"</item>
<item>"buka OpenClaw dan tanyakan %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"chiedi a OpenClaw %1$s"</item>
<item>"di\' a OpenClaw di %1$s"</item>
<item>"apri OpenClaw e chiedi %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"OpenClaw に %1$s を尋ねる"</item>
<item>"OpenClaw に %1$s するよう伝える"</item>
<item>"OpenClaw を開いて %1$s を尋ねる"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"OpenClaw에 %1$s 요청"</item>
<item>"OpenClaw에 %1$s 지시"</item>
<item>"OpenClaw을 열고 %1$s 요청"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"vraag OpenClaw %1$s"</item>
<item>"zeg tegen OpenClaw om %1$s"</item>
<item>"open OpenClaw en vraag %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"zapytaj OpenClaw %1$s"</item>
<item>"powiedz OpenClaw, aby %1$s"</item>
<item>"otwórz OpenClaw i zapytaj %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"perguntar ao OpenClaw %1$s"</item>
<item>"dizer ao OpenClaw para %1$s"</item>
<item>"abrir o OpenClaw e perguntar %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"спросить OpenClaw %1$s"</item>
<item>"сказать OpenClaw %1$s"</item>
<item>"открыть OpenClaw и спросить %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"fråga OpenClaw %1$s"</item>
<item>"be OpenClaw att %1$s"</item>
<item>"öppna OpenClaw och fråga %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"ถาม OpenClaw %1$s"</item>
<item>"บอก OpenClaw ให้ %1$s"</item>
<item>"เปิด OpenClaw แล้วถาม %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"OpenClaw\'a %1$s sor"</item>
<item>"OpenClaw\'a %1$s söyle"</item>
<item>"OpenClaw\'ı aç ve %1$s sor"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"запитати OpenClaw %1$s"</item>
<item>"сказати OpenClaw %1$s"</item>
<item>"відкрити OpenClaw і запитати %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"hỏi OpenClaw %1$s"</item>
<item>"yêu cầu OpenClaw %1$s"</item>
<item>"mở OpenClaw và hỏi %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"询问 OpenClaw %1$s"</item>
<item>"告诉 OpenClaw %1$s"</item>
<item>"打开 OpenClaw 并询问 %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>"詢問 OpenClaw %1$s"</item>
<item>"告訴 OpenClaw %1$s"</item>
<item>"開啟 OpenClaw 並詢問 %1$s"</item>
</string-array>
</resources>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+603 -41
View File
@@ -1,4 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
@@ -6,8 +7,30 @@ import { NATIVE_I18N_LOCALES } from "./native-app-i18n.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const RESOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main", "res");
const SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
const ANDROID_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
const RESOURCE_ROOT = path.join(ANDROID_ROOT, "res");
const SOURCE_ROOT = path.join(ANDROID_ROOT, "java");
const INVENTORY_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
const ARTIFACT_ROOT = path.join(ROOT, "apps", ".i18n", "native");
const TOOL_DISPLAY_PATH = path.join(
ROOT,
"apps",
"shared",
"OpenClawKit",
"Sources",
"OpenClawKit",
"Resources",
"tool-display.json",
);
const GENERATED_KOTLIN_PATH = path.join(
SOURCE_ROOT,
"ai",
"openclaw",
"app",
"i18n",
"NativeStringResources.kt",
);
const MANAGED_PREFIX = "native_";
const ANDROID_QUALIFIERS: Record<string, string> = {
id: "in",
"zh-CN": "zh-rCN",
@@ -17,66 +40,596 @@ const ANDROID_QUALIFIERS: Record<string, string> = {
};
const localeDirectory = (locale: string) => `values-${ANDROID_QUALIFIERS[locale] ?? locale}`;
const LOCALES = ["values", ...NATIVE_I18N_LOCALES.map(localeDirectory)] as const;
const STRING_RE = /<string\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/string>/gu;
const STRING_RE = /<string\s+name="([A-Za-z0-9_]+)"([^>]*)>([\s\S]*?)<\/string>/gu;
const ARRAY_RE = /<string-array\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/string-array>/gu;
const ARRAY_ITEM_RE = /<item>([\s\S]*?)<\/item>/gu;
const FORMAT_RE = /%\d+\$[a-z]/giu;
const INVALID_APOSTROPHE_RE = /(?:&apos;|(?<!\\)')/u;
const INTERPOLATION_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^{}]+\})/gu;
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
async function readStrings(locale: string): Promise<Map<string, string>> {
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
type NativeInventoryEntry = {
id: string;
path: string;
source: string;
surface: "android" | "apple";
};
type NativeArtifactEntry = {
id: string;
source: string;
translated: string;
};
type ResourceString = {
attrs: string;
key: string;
rawValue: string;
value: string;
};
type TranslationContradiction = {
locale: string;
selected: string;
source: string;
translations: string[];
};
type GeneratedCatalog = {
contradictions: TranslationContradiction[];
kotlin: string;
resources: Map<string, string>;
sources: Set<string>;
};
export type AndroidUiLiteralFinding = {
line: number;
path: string;
source: string;
};
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function decodeXml(value: string): string {
return value
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&amp;", "&")
.replaceAll("\\n", "\n")
.replaceAll("\\'", "'")
.replaceAll('\\"', '"')
.replaceAll("\\\\", "\\");
}
export function renderAndroidResourceValue(source: string, translated: string): string {
let rendered = translated;
const sourceTokens = [...source.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
if (sourceTokens.length > 0) {
rendered = rendered.replaceAll("%", "%%");
const sourceIndices = new Map<string, number[]>();
for (const [index, token] of sourceTokens.entries()) {
const indices = sourceIndices.get(token) ?? [];
indices.push(index + 1);
sourceIndices.set(token, indices);
}
const translatedOccurrences = new Map<string, number>();
rendered = rendered.replace(INTERPOLATION_RE, (token) => {
const occurrence = translatedOccurrences.get(token) ?? 0;
translatedOccurrences.set(token, occurrence + 1);
const index = sourceIndices.get(token)?.[occurrence];
return index ? `%${index}$s` : token;
});
}
return rendered
.replaceAll("\\", "\\\\")
.replaceAll("\n", "\\n")
.replaceAll("'", "\\'")
.replaceAll('"', '\\"')
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeKotlin(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("$", "\\$")
.replaceAll("\n", "\\n");
}
function resourceKey(source: string): string {
return `${MANAGED_PREFIX}${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
}
function parseStrings(source: string): ResourceString[] {
return [...source.matchAll(STRING_RE)].map((match) => ({
key: match[1] ?? "",
attrs: match[2] ?? "",
rawValue: match[3] ?? "",
value: decodeXml(match[3] ?? ""),
}));
}
function parseArrays(source: string): Map<string, string[]> {
return new Map(
[...source.matchAll(STRING_RE)]
.map((match) => [match[1], match[2]] as const)
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
[...source.matchAll(ARRAY_RE)].map((match) => [
match[1] ?? "",
[...(match[2] ?? "").matchAll(ARRAY_ITEM_RE)].map((item) =>
decodeXml((item[1] ?? "").trim()),
),
]),
);
}
async function readAndroidSource(root = SOURCE_ROOT): Promise<string> {
async function readStrings(locale: string): Promise<Map<string, ResourceString>> {
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
}
async function readAndroidSource(
root = SOURCE_ROOT,
): Promise<Array<{ path: string; source: string }>> {
const entries = await readdir(root, { withFileTypes: true });
const sources: string[] = [];
const sources: Array<{ path: string; source: string }> = [];
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
if (fullPath.startsWith(`${RESOURCE_ROOT}${path.sep}values`)) {
continue;
}
sources.push(await readAndroidSource(fullPath));
sources.push(...(await readAndroidSource(fullPath)));
continue;
}
if (entry.isFile() && /\.(?:kt|kts|xml)$/u.test(entry.name)) {
sources.push(await readFile(fullPath, "utf8"));
if (entry.isFile() && entry.name.endsWith(".kt")) {
sources.push({
path: path.relative(ROOT, fullPath).split(path.sep).join("/"),
source: await readFile(fullPath, "utf8"),
});
}
}
return sources.join("\n");
return sources;
}
function findInvalidResourceSyntax(strings: Map<string, string>): string[] {
function lineNumber(source: string, offset: number): number {
return source.slice(0, offset).split("\n").length;
}
function decodeKotlinLiteral(value: string): string {
return value
.replaceAll("\\n", "\n")
.replaceAll('\\"', '"')
.replaceAll("\\$", "$")
.replaceAll("\\\\", "\\");
}
function collectExplicitRuntimeSources(
sourceFiles: readonly { path: string; source: string }[],
): Set<string> {
const sources = new Set<string>();
const callPattern = /\bnativeString(?:Resource)?\(\s*"((?:\\.|[^"\\])+)"/gu;
for (const file of sourceFiles) {
if (file.path.endsWith("/i18n/NativeStringResources.kt")) continue;
for (const match of file.source.matchAll(callPattern)) {
if (match[1]) sources.add(decodeKotlinLiteral(match[1]));
}
}
return sources;
}
function collectToolDisplaySources(value: unknown, sources = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
for (const item of value) collectToolDisplaySources(item, sources);
return sources;
}
if (value === null || typeof value !== "object") return sources;
for (const [key, item] of Object.entries(value)) {
if ((key === "title" || key === "label") && typeof item === "string") {
sources.add(item);
} else {
collectToolDisplaySources(item, sources);
}
}
return sources;
}
async function readToolDisplaySources(): Promise<Set<string>> {
return collectToolDisplaySources(JSON.parse(await readFile(TOOL_DISPLAY_PATH, "utf8")));
}
const DIRECT_UI_LITERAL_PATTERNS = [
/\bText\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])+)"/gu,
/\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"((?:\\.|[^"\\])+)"/gsu,
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])+)"/gu,
/\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"((?:\\.|[^"\\])+)"/gu,
/\bSettingsMetric\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
/\bToast\.makeText\s*\([^,]+,\s*"((?:\\.|[^"\\])+)"/gu,
/\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
] as const;
// These literals are either technical data or immutable source tokens localized at a typed render edge.
const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
[
"*",
new Set([
"0 = exact",
"Cron expression, e.g. 0 9 * * *",
"D",
"ID",
"ISO time, e.g. 2026-07-09T09:30:00Z",
"OC",
"OPENCLAW",
"e.g. America/New_York",
"current-step-alpha",
"gateway-progress",
"main, isolated, current, or session:<id>",
"openclaw gateway",
"openclaw qr",
"PTT_BUSY: previous push-to-talk turn is still finishing",
]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt",
new Set([
"Connected",
"Continue",
"Checking approval…",
"Connecting Gateway",
"Connection issue",
"Go back",
"I have approved",
"Node Approval Pending",
"Pairing Gateway",
"Retry connection",
"Still connecting",
]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt",
new Set(["Chat", "Files", "Home", "Providers", "Sessions", "Settings", "Voice"]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
new Set([
"Ask OpenClaw to use Android capabilities.",
"Catch me up",
"Plan the work",
"Summarize recent sessions and next steps.",
"Turn a goal into an actionable checklist.",
"Use this phone",
]),
],
]);
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
return (
ALLOWED_UI_LITERALS.get("*")?.has(source) === true ||
ALLOWED_UI_LITERALS.get(repoPath)?.has(source) === true
);
}
function shouldScanUiLiterals(repoPath: string): boolean {
if (repoPath.endsWith("/i18n/NativeStringResources.kt")) return false;
if (repoPath.endsWith("/AndroidScreenshotFixture.kt")) return false;
if (repoPath.endsWith("/ui/design/ClawComponents.kt")) return false;
if (repoPath.endsWith("/ui/design/OpenClawMascot.kt")) return false;
return (
repoPath.includes("/ui/") ||
repoPath.endsWith("/MainActivity.kt") ||
repoPath.endsWith("/NodeRuntime.kt") ||
repoPath.endsWith("/PermissionRequester.kt") ||
repoPath.endsWith("/NodeForegroundService.kt") ||
repoPath.endsWith("/chat/ChatController.kt") ||
repoPath.endsWith("/voice/MicCaptureManager.kt") ||
repoPath.endsWith("/voice/TalkModeManager.kt") ||
repoPath.endsWith("/node/SystemHandler.kt")
);
}
export function findUnlocalizedAndroidUiLiterals(
source: string,
repoPath: string,
): AndroidUiLiteralFinding[] {
if (!shouldScanUiLiterals(repoPath)) return [];
const findings = new Map<string, AndroidUiLiteralFinding>();
for (const pattern of DIRECT_UI_LITERAL_PATTERNS) {
pattern.lastIndex = 0;
for (const match of source.matchAll(pattern)) {
const literal = match[1];
if (!literal) continue;
const matchText = match[0] ?? "";
if (
matchText.includes("nativeStringResource(") ||
matchText.includes("nativeString(") ||
isAllowedUiLiteral(repoPath, decodeKotlinLiteral(literal)) ||
literal.startsWith("http") ||
literal.startsWith("content://")
) {
continue;
}
const offset = (match.index ?? 0) + matchText.lastIndexOf(`"${literal}"`);
const finding = {
line: lineNumber(source, Math.max(0, offset)),
path: repoPath,
source: decodeKotlinLiteral(literal),
};
findings.set(`${finding.line}\u0000${finding.source}`, finding);
}
}
return [...findings.values()].toSorted(
(left, right) => left.line - right.line || compareText(left.source, right.source),
);
}
function findInvalidResourceSyntax(strings: Map<string, ResourceString>): string[] {
return [...strings]
.filter(([, value]) => {
const trimmed = value.trim();
.filter(([, entry]) => {
const trimmed = entry.rawValue.trim();
const isQuoted = trimmed.startsWith('"') && trimmed.endsWith('"');
return !isQuoted && INVALID_APOSTROPHE_RE.test(trimmed);
})
.map(([key]) => key);
}
export function selectDeterministicTranslation(values: readonly string[]): string {
const counts = new Map<string, number>();
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return (
[...counts]
.toSorted(
([left, leftCount], [right, rightCount]) =>
rightCount - leftCount || compareText(left, right),
)
.at(0)?.[0] ?? ""
);
}
async function readInventory(): Promise<NativeInventoryEntry[]> {
const parsed = JSON.parse(await readFile(INVENTORY_PATH, "utf8")) as {
entries?: NativeInventoryEntry[];
};
return (parsed.entries ?? []).filter((entry) => entry.surface === "android");
}
async function readArtifacts(): Promise<Map<string, NativeArtifactEntry[]>> {
return new Map(
await Promise.all(
NATIVE_I18N_LOCALES.map(async (locale) => {
const parsed = JSON.parse(
await readFile(path.join(ARTIFACT_ROOT, `${locale}.json`), "utf8"),
) as { entries?: NativeArtifactEntry[] };
return [locale, parsed.entries ?? []] as const;
}),
),
);
}
function renderStringsXml(
manual: readonly ResourceString[],
generated: ReadonlyMap<string, { source: string; value: string }>,
): string {
const lines = ["<resources>"];
for (const entry of manual) {
lines.push(` <string name="${entry.key}"${entry.attrs}>${entry.rawValue}</string>`);
}
if (generated.size > 0) {
lines.push(GENERATED_HEADER);
for (const [key, entry] of [...generated].toSorted(([left], [right]) =>
compareText(left, right),
)) {
const formatted = INTERPOLATION_RE.test(entry.source) ? "" : ' formatted="false"';
INTERPOLATION_RE.lastIndex = 0;
lines.push(
` <string name="${key}"${formatted}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
);
}
}
lines.push("</resources>", "");
return lines.join("\n");
}
function renderAssistantXml(items: readonly string[]): string {
return [
"<resources>",
' <string-array name="ask_openclaw_query_patterns">',
...items.map((item) => ` <item>"${renderAndroidResourceValue(item, item)}"</item>`),
" </string-array>",
"</resources>",
"",
].join("\n");
}
function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
const entries = [...sourceToKey].toSorted(([left], [right]) => compareText(left, right));
return [
GENERATED_KOTLIN_HEADER,
"package ai.openclaw.app.i18n",
"",
"import ai.openclaw.app.R",
"",
"internal val nativeStringResourceIds: Map<String, Int> =",
" mapOf(",
...entries.map(([source, key]) => ` "${escapeKotlin(source)}" to R.string.${key},`),
" )",
"",
].join("\n");
}
async function buildCatalog(): Promise<GeneratedCatalog> {
const [inventory, artifacts, localeStrings, sourceFiles, toolDisplaySources] = await Promise.all([
readInventory(),
readArtifacts(),
Promise.all(LOCALES.map(readStrings)),
readAndroidSource(),
readToolDisplaySources(),
]);
const [baseStrings, ...translatedStrings] = localeStrings;
const manualBase = [...baseStrings.values()].filter(
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
);
const manualSourceToKey = new Map(manualBase.map((entry) => [entry.value, entry.key]));
const entriesBySource = new Map<string, NativeInventoryEntry[]>();
for (const entry of inventory) {
const group = entriesBySource.get(entry.source) ?? [];
group.push(entry);
entriesBySource.set(entry.source, group);
}
for (const source of collectExplicitRuntimeSources(sourceFiles)) {
if (!entriesBySource.has(source)) entriesBySource.set(source, []);
}
for (const source of toolDisplaySources) {
if (!entriesBySource.has(source)) entriesBySource.set(source, []);
}
const sourceToKey = new Map<string, string>();
for (const source of entriesBySource.keys()) {
sourceToKey.set(source, manualSourceToKey.get(source) ?? resourceKey(source));
}
const resources = new Map<string, string>();
const contradictions: TranslationContradiction[] = [];
for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) {
const manualTranslations = translatedStrings[localeIndex] ?? new Map();
const artifactTranslationsBySource = new Map<string, string[]>();
for (const entry of artifacts.get(locale) ?? []) {
const values = artifactTranslationsBySource.get(entry.source) ?? [];
values.push(entry.translated);
artifactTranslationsBySource.set(entry.source, values);
}
const generated = new Map<string, { source: string; value: string }>();
for (const source of entriesBySource.keys()) {
const key = sourceToKey.get(source);
if (!key || !key.startsWith(MANAGED_PREFIX)) continue;
const translations = artifactTranslationsBySource.get(source) ?? [];
const selected = selectDeterministicTranslation(translations);
const unique = [...new Set(translations)].toSorted(compareText);
if (unique.length > 1) {
contradictions.push({ locale, selected, source, translations: unique });
}
generated.set(key, {
source,
value: selected || source,
});
}
const manual = [...manualTranslations.values()].filter(
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
);
resources.set(
path.join(RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
renderStringsXml(manual, generated),
);
}
const generatedBase = new Map<string, { source: string; value: string }>();
for (const [source, key] of sourceToKey) {
if (!key.startsWith(MANAGED_PREFIX)) continue;
generatedBase.set(key, {
source,
value: source,
});
}
resources.set(
path.join(RESOURCE_ROOT, "values", "strings.xml"),
renderStringsXml(manualBase, generatedBase),
);
const assistantSource = await readFile(
path.join(RESOURCE_ROOT, "values", "assistant.xml"),
"utf8",
);
const assistantItems = parseArrays(assistantSource).get("ask_openclaw_query_patterns") ?? [];
for (const [locale, artifactEntries] of artifacts) {
const translatedBySource = new Map<string, string[]>();
for (const entry of artifactEntries) {
const values = translatedBySource.get(entry.source) ?? [];
values.push(entry.translated);
translatedBySource.set(entry.source, values);
}
const translatedItems = assistantItems.map(
(source) => selectDeterministicTranslation(translatedBySource.get(source) ?? []) || source,
);
resources.set(
path.join(RESOURCE_ROOT, localeDirectory(locale), "assistant.xml"),
renderAssistantXml(translatedItems),
);
}
return {
contradictions,
kotlin: renderKotlin(sourceToKey),
resources,
sources: new Set(sourceToKey.keys()),
};
}
function formatProblems(problems: Array<readonly [string, string[]]>): string {
return [
"Android app i18n resources are out of sync.",
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
].join("\n");
}
export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
const catalog = await buildCatalog();
const drift: string[] = [];
for (const [filePath, expected] of catalog.resources) {
const current = await readFile(filePath, "utf8").catch(() => "");
if (current === expected) continue;
drift.push(path.relative(ROOT, filePath).split(path.sep).join("/"));
if (!options.check) {
await writeFile(filePath, expected);
}
}
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
if (currentKotlin !== catalog.kotlin) {
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
if (!options.check) {
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
}
}
if (options.check && drift.length > 0) {
throw new Error(`Android generated localization drift:\n${drift.join("\n")}`);
}
if (catalog.contradictions.length > 0) {
const limit = 20;
const visible = catalog.contradictions.slice(0, limit);
const remaining = catalog.contradictions.length - visible.length;
process.stderr.write(
[
`android-app-i18n: contradictions=${catalog.contradictions.length}`,
...visible.map(
(finding) =>
`${finding.locale}: ${JSON.stringify(finding.source)} -> ${JSON.stringify(finding.selected)} (${finding.translations.map(JSON.stringify).join(", ")})`,
),
...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []),
"",
].join("\n"),
);
}
return catalog;
}
export async function checkAndroidAppI18n() {
const [source, localeStrings] = await Promise.all([
const [sourceFiles, localeStrings] = await Promise.all([
readAndroidSource(),
Promise.all(LOCALES.map(readStrings)),
]);
await syncAndroidAppI18n({ check: true });
const base = expectDefined(localeStrings[0], "English Android string resources");
const translations = localeStrings.slice(1);
const baseKeys = new Set(base.keys());
const problems: Array<readonly [string, string[]]> = translations.flatMap((strings, index) => {
const locale = NATIVE_I18N_LOCALES[index];
const keys = new Set(strings.keys());
const placeholderMismatches = [...base].flatMap(([key, sourceValue]) => {
const translatedValue = strings.get(key);
if (!translatedValue) {
return [];
}
const expected = [...sourceValue.matchAll(FORMAT_RE)].map((match) => match[0]).toSorted();
const actual = [...translatedValue.matchAll(FORMAT_RE)].map((match) => match[0]).toSorted();
const placeholderMismatches = [...base].flatMap(([key, sourceEntry]) => {
const translatedEntry = strings.get(key);
if (!translatedEntry) return [];
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
});
return [
@@ -87,17 +640,23 @@ export async function checkAndroidAppI18n() {
] as const;
});
problems.push(["English syntax", findInvalidResourceSyntax(base)]);
const unusedBaseKeys = [...baseKeys].filter(
(key) => !source.includes(`R.string.${key}`) && !source.includes(`@string/${key}`),
const allSource = sourceFiles.map((file) => file.source).join("\n");
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
problems.push([
"English unused",
manualBaseKeys.filter(
(key) => !allSource.includes(`R.string.${key}`) && !allSource.includes(`@string/${key}`),
),
]);
const uiFindings = sourceFiles.flatMap((file) =>
findUnlocalizedAndroidUiLiterals(file.source, file.path),
);
problems.push(["English unused", unusedBaseKeys]);
problems.push([
"Unlocalized UI literals",
uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`),
]);
if (problems.some(([, keys]) => keys.length)) {
throw new Error(
[
"Android app i18n resources are out of sync.",
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
].join("\n"),
);
throw new Error(formatProblems(problems));
}
process.stdout.write(
`android-app-i18n: keys=${baseKeys.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
@@ -106,8 +665,11 @@ export async function checkAndroidAppI18n() {
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
const [command] = process.argv.slice(2);
if (command !== "check") {
throw new Error("usage: node --import tsx scripts/android-app-i18n.ts check");
if (command === "sync") {
await syncAndroidAppI18n();
} else if (command === "check") {
await checkAndroidAppI18n();
} else {
throw new Error("usage: node --import tsx scripts/android-app-i18n.ts <sync|check>");
}
await checkAndroidAppI18n();
}
+49 -2
View File
@@ -1,9 +1,14 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { checkAndroidAppI18n } from "../../scripts/android-app-i18n.ts";
import {
checkAndroidAppI18n,
findUnlocalizedAndroidUiLiterals,
renderAndroidResourceValue,
selectDeterministicTranslation,
} from "../../scripts/android-app-i18n.ts";
describe("Android app i18n resources", () => {
it("keeps every native locale resource key aligned with English", async () => {
it("keeps generated resources, runtime coverage, and every locale aligned", async () => {
await expect(checkAndroidAppI18n()).resolves.toBeUndefined();
});
@@ -11,4 +16,46 @@ describe("Android app i18n resources", () => {
const strings = await readFile("apps/android/app/src/main/res/values-sv/strings.xml", "utf8");
expect(strings).toContain('<string name="app_name">OpenClaw-nod</string>');
});
it("selects duplicate-source translations by frequency then stable text order", () => {
expect(selectDeterministicTranslation(["Beta", "Alpha", "Beta"])).toBe("Beta");
expect(selectDeterministicTranslation(["Beta", "Alpha"])).toBe("Alpha");
});
it("preserves source argument indexes when a translation reorders interpolations", () => {
expect(
renderAndroidResourceValue(
"$readyProviderCount of $providerCount providers ready",
"$providerCount Anbieter, davon $readyProviderCount bereit",
),
).toBe("%2$s Anbieter, davon %1$s bereit");
});
it("finds direct UI literals but ignores localized calls and preview fixtures", () => {
const source = `
Text("Settings")
Text(text = nativeStringResource("Connected"))
ClawPrimaryButton(text = "Continue", onClick = {})
ClawStatusPill(text = "Working")
SettingsMetric("Gateway", gatewayName)
val dynamic = Text(text = gateway.name)
`;
expect(
findUnlocalizedAndroidUiLiterals(
source,
"apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt",
),
).toEqual([
expect.objectContaining({ source: "Settings" }),
expect.objectContaining({ source: "Continue" }),
expect.objectContaining({ source: "Working" }),
expect.objectContaining({ source: "Gateway" }),
]);
expect(
findUnlocalizedAndroidUiLiterals(
'Text("Preview copy")',
"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt",
),
).toEqual([]);
});
});