Download scripts/android-app-i18n.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 56.1 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/scripts/android-app-i18n.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/scripts/android-app-i18n.ts
-
curl -L -o android-app-i18n.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/scripts/android-app-i18n.ts
56.1 kB
| import { createHash } from "node:crypto"; | |
| import { mkdir, 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"; | |
| import { isDirectRunUrl } from "./lib/direct-run.mjs"; | |
| import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts"; | |
| const HERE = path.dirname(fileURLToPath(import.meta.url)); | |
| const ROOT = path.resolve(HERE, ".."); | |
| const ANDROID_MAIN_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main"); | |
| const RESOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "res"); | |
| const SOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "java"); | |
| const ANDROID_PLAY_SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "play", "java"); | |
| const ANDROID_THIRD_PARTY_ROOT = path.join(ROOT, "apps", "android", "app", "src", "thirdParty"); | |
| const ANDROID_THIRD_PARTY_RESOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "res"); | |
| const ANDROID_THIRD_PARTY_SOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "java"); | |
| const ANDROID_WEAR_MAIN_ROOT = path.join(ROOT, "apps", "android", "wear", "src", "main"); | |
| const WEAR_RESOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "res"); | |
| const WEAR_SOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "java"); | |
| const ANDROID_SOURCE_ROOTS = [ | |
| SOURCE_ROOT, | |
| ANDROID_PLAY_SOURCE_ROOT, | |
| ANDROID_THIRD_PARTY_SOURCE_ROOT, | |
| WEAR_SOURCE_ROOT, | |
| ] as const; | |
| 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", | |
| "zh-TW": "zh-rTW", | |
| "pt-BR": "pt-rBR", | |
| "ja-JP": "ja", | |
| }; | |
| 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 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 ANDROID_REFERENCE_COMMENT_RE = | |
| /("""[\s\S]*?"""|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|<!--[\s\S]*?-->|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/gu; | |
| const XML_COMMENT_RE = /<!--[\s\S]*?-->/gu; | |
| const FORMAT_RE = /%\d+\$[a-z]/giu; | |
| const INVALID_APOSTROPHE_RE = /(?:'|(?<!\\)')/u; | |
| const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->"; | |
| const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit."; | |
| const THIRD_PARTY_STRINGS_FILE = "accessibility_strings.xml"; | |
| const THIRD_PARTY_STRINGS_REPO_PATH = | |
| "apps/android/app/src/thirdParty/res/values/accessibility_strings.xml"; | |
| const THIRD_PARTY_GENERATED_RESOURCE_RE = | |
| /^apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml$/; | |
| const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xml"; | |
| const WEAR_GENERATED_RESOURCE_RE = | |
| /^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/; | |
| type NativeInventoryEntry = { | |
| id: string; | |
| source: string; | |
| sites: Array<{ kind: string; path: string }>; | |
| surface: "android" | "apple"; | |
| }; | |
| type NativeTranslations = Record<string, string>; | |
| type ResourceString = { | |
| attrs: string; | |
| key: string; | |
| rawValue: string; | |
| value: string; | |
| }; | |
| const GENERATED_TRANSLATION_LINT_IGNORES = [ | |
| "Typos", | |
| "TypographyDashes", | |
| "TypographyEllipsis", | |
| ] as const; | |
| type TranslationContradiction = { | |
| locale: string; | |
| selected: string; | |
| source: string; | |
| translations: string[]; | |
| }; | |
| export 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("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll(""", '"') | |
| .replaceAll("&", "&") | |
| .replaceAll("\\n", "\n") | |
| .replaceAll("\\'", "'") | |
| .replaceAll('\\"', '"') | |
| .replaceAll("\\\\", "\\"); | |
| } | |
| export function decodeAndroidResourceValue(rawValue: string): string { | |
| const trimmed = rawValue.trim(); | |
| const unquoted = | |
| trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed; | |
| return decodeXml(unquoted); | |
| } | |
| type KotlinInterpolation = { | |
| end: number; | |
| start: number; | |
| value: string; | |
| }; | |
| function readKotlinInterpolations(source: string): KotlinInterpolation[] | null { | |
| const interpolations: KotlinInterpolation[] = []; | |
| for (let index = 0; index < source.length; index += 1) { | |
| if (source[index] !== "$") { | |
| continue; | |
| } | |
| const next = source[index + 1]; | |
| if (next !== "{" && !/[A-Za-z_]/u.test(next ?? "")) { | |
| continue; | |
| } | |
| const start = index; | |
| if (next !== "{") { | |
| index += 2; | |
| while (/[A-Za-z0-9_]/u.test(source[index] ?? "")) { | |
| index += 1; | |
| } | |
| interpolations.push({ start, end: index, value: source.slice(start, index) }); | |
| index -= 1; | |
| continue; | |
| } | |
| let depth = 1; | |
| let quote: '"' | "'" | null = null; | |
| let escaped = false; | |
| for (index += 2; index < source.length; index += 1) { | |
| const character = source[index]; | |
| if (escaped) { | |
| escaped = false; | |
| } else if (quote !== null && character === "\\") { | |
| escaped = true; | |
| } else if (character === quote) { | |
| quote = null; | |
| } else if (quote === null && (character === '"' || character === "'")) { | |
| quote = character; | |
| } else if (quote === null && character === "{") { | |
| depth += 1; | |
| } else if (quote === null && character === "}") { | |
| depth -= 1; | |
| if (depth === 0) { | |
| const end = index + 1; | |
| interpolations.push({ start, end, value: source.slice(start, end) }); | |
| break; | |
| } | |
| } | |
| } | |
| if (depth !== 0) { | |
| return null; | |
| } | |
| } | |
| return interpolations; | |
| } | |
| export function renderAndroidResourceValue(source: string, translated: string): string { | |
| let rendered = translated; | |
| const sourceInterpolations = readKotlinInterpolations(source); | |
| const translatedInterpolations = readKotlinInterpolations(translated); | |
| if (sourceInterpolations === null || translatedInterpolations === null) { | |
| throw new Error( | |
| `Android translation has unbalanced interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`, | |
| ); | |
| } | |
| const sourceTokens = sourceInterpolations.map((interpolation) => interpolation.value); | |
| const translatedTokens = translatedInterpolations.map((interpolation) => interpolation.value); | |
| const tokenCounts = (tokens: readonly string[]) => { | |
| const counts = new Map<string, number>(); | |
| for (const token of tokens) { | |
| counts.set(token, (counts.get(token) ?? 0) + 1); | |
| } | |
| return [...counts].toSorted(([left], [right]) => compareText(left, right)); | |
| }; | |
| if (JSON.stringify(tokenCounts(sourceTokens)) !== JSON.stringify(tokenCounts(translatedTokens))) { | |
| throw new Error( | |
| `Android translation changed interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`, | |
| ); | |
| } | |
| if (sourceTokens.length > 0) { | |
| 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>(); | |
| let cursor = 0; | |
| rendered = ""; | |
| for (const interpolation of translatedInterpolations) { | |
| rendered += translated.slice(cursor, interpolation.start).replaceAll("%", "%%"); | |
| const token = interpolation.value; | |
| const occurrence = translatedOccurrences.get(token) ?? 0; | |
| translatedOccurrences.set(token, occurrence + 1); | |
| const index = sourceIndices.get(token)?.[occurrence]; | |
| rendered += index ? `%${index}$s` : token; | |
| cursor = interpolation.end; | |
| } | |
| rendered += translated.slice(cursor).replaceAll("%", "%%"); | |
| } | |
| return escapeAndroidResourceValue(rendered); | |
| } | |
| export function escapeAndroidResourceValue(value: string): string { | |
| return value | |
| .replaceAll("\\", "\\\\") | |
| .replaceAll("\n", "\\n") | |
| .replaceAll("'", "\\'") | |
| .replaceAll('"', '\\"') | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">"); | |
| } | |
| 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 withGeneratedTranslationLintIgnores(attrs: string): string { | |
| const existing = attrs.match(/\btools:ignore\s*=\s*"([^"]*)"/u); | |
| const ignores = [ | |
| ...(existing?.[1] | |
| ?.split(",") | |
| .map((value) => value.trim()) | |
| .filter(Boolean) ?? []), | |
| ...GENERATED_TRANSLATION_LINT_IGNORES, | |
| ]; | |
| const rendered = `tools:ignore="${[...new Set(ignores)].join(",")}"`; | |
| return existing ? attrs.replace(existing[0], rendered) : `${attrs} ${rendered}`; | |
| } | |
| function parseArrays(source: string): Map<string, string[]> { | |
| return new Map( | |
| [...source.matchAll(ARRAY_RE)].map((match) => [ | |
| match[1] ?? "", | |
| [...(match[2] ?? "").matchAll(ARRAY_ITEM_RE)].map((item) => | |
| decodeXml((item[1] ?? "").trim()), | |
| ), | |
| ]), | |
| ); | |
| } | |
| async function readStrings( | |
| locale: string, | |
| resourceRoot = RESOURCE_ROOT, | |
| fileName = "strings.xml", | |
| ): Promise<Map<string, ResourceString>> { | |
| const source = await readFile(path.join(resourceRoot, locale, fileName), "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: Array<{ path: string; source: string }> = []; | |
| for (const entry of entries) { | |
| const fullPath = path.join(root, entry.name); | |
| if (entry.isDirectory()) { | |
| sources.push(...(await readAndroidSource(fullPath))); | |
| continue; | |
| } | |
| 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; | |
| } | |
| async function readAllAndroidSource(): Promise<Array<{ path: string; source: string }>> { | |
| return (await Promise.all(ANDROID_SOURCE_ROOTS.map((root) => readAndroidSource(root)))).flat(); | |
| } | |
| type AndroidResourceReferenceSource = { | |
| path: string; | |
| source: string; | |
| }; | |
| async function readAndroidResourceReferences( | |
| root = ANDROID_MAIN_ROOT, | |
| ): Promise<AndroidResourceReferenceSource[]> { | |
| const entries = await readdir(root, { withFileTypes: true }); | |
| const sources: AndroidResourceReferenceSource[] = []; | |
| for (const entry of entries) { | |
| const fullPath = path.join(root, entry.name); | |
| if (entry.isDirectory()) { | |
| // This walk is confined to app/src/main, so Gradle build output is never eligible. | |
| sources.push(...(await readAndroidResourceReferences(fullPath))); | |
| continue; | |
| } | |
| if (entry.isFile() && /\.(?:kt|kts|xml)$/u.test(entry.name)) { | |
| sources.push({ | |
| path: path.relative(ROOT, fullPath).split(path.sep).join("/"), | |
| source: await readFile(fullPath, "utf8"), | |
| }); | |
| } | |
| } | |
| return sources; | |
| } | |
| export function findUnusedAndroidResourceKeys( | |
| keys: Iterable<string>, | |
| referenceSources: readonly AndroidResourceReferenceSource[], | |
| ): string[] { | |
| const references = new Set<string>(); | |
| for (const reference of referenceSources) { | |
| const isXml = reference.path.endsWith(".xml"); | |
| const source = reference.source.replace( | |
| isXml ? XML_COMMENT_RE : ANDROID_REFERENCE_COMMENT_RE, | |
| (match) => match.replace(/[^\r\n]/gu, " "), | |
| ); | |
| const pattern = isXml ? /@string\/([A-Za-z0-9_]+)\b/gu : /\bR\.string\.([A-Za-z0-9_]+)\b/gu; | |
| for (const match of source.matchAll(pattern)) { | |
| if (match[1]) { | |
| references.add(match[1]); | |
| } | |
| } | |
| } | |
| return [...keys].filter((key) => !references.has(key)); | |
| } | |
| function lineNumber(source: string, offset: number): number { | |
| return source.slice(0, offset).split("\n").length; | |
| } | |
| function decodeKotlinLiteral(value: string): string { | |
| return value.replace( | |
| /\\(?:u([0-9a-fA-F]{4})|[n"$\\])/gu, | |
| (escape, codeUnit: string | undefined) => { | |
| if (codeUnit) { | |
| return String.fromCharCode(Number.parseInt(codeUnit, 16)); | |
| } | |
| return escape === "\\n" ? "\n" : escape.slice(1); | |
| }, | |
| ); | |
| } | |
| 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|onClickLabel)\s*=\s*"((?:\\.|[^"\\])+)"/gu, | |
| /\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\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, | |
| /\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"((?:\\.|[^"\\])+)"/gsu, | |
| ] as const; | |
| const DIRECT_UI_RAW_LITERAL_PATTERNS = [ | |
| /\bText\s*\(\s*(?:text\s*=\s*)?"""([\s\S]*?)"""/gu, | |
| /\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"""([\s\S]*?)"""/gsu, | |
| /\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*"""([\s\S]*?)"""/gu, | |
| /\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\s*"""([\s\S]*?)"""/gu, | |
| /\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"""([\s\S]*?)"""/gu, | |
| /\bSettingsMetric\s*\(\s*"""([\s\S]*?)"""/gu, | |
| /\bToast\.makeText\s*\([^,]+,\s*"""([\s\S]*?)"""/gu, | |
| /\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"""([\s\S]*?)"""/gu, | |
| /\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"""([\s\S]*?)"""/gsu, | |
| ] as const; | |
| const UI_STRING_HELPER_NAME_RE = | |
| /(?:description|detail|error|label|message|nextRun|notice|report|status|subtitle|summary|text|title)$/iu; | |
| const UI_STRING_FUNCTION_RE = /\bfun\s+(?:<[^>{}]*>\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*/gu; | |
| const UI_STRING_PROPERTY_RE = | |
| /\bval\s+(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*(?:\n\s*)?get\(\)\s*=\s*/gu; | |
| const UI_BRANCH_LITERAL_RE = /(?:->|return|\?:)\s*"((?:\\.|[^"\\])+)"/gu; | |
| const UI_ELSE_BRANCH_LITERAL_RE = /\belse\s*\{?\s*"((?:\\.|[^"\\])+)"/gu; | |
| const UI_BRANCH_RAW_LITERAL_RE = /(?:->|return|\?:)\s*"""([\s\S]*?)"""/gu; | |
| const UI_ELSE_BRANCH_RAW_LITERAL_RE = /\belse\s*\{?\s*"""([\s\S]*?)"""/gu; | |
| const UI_MODEL_STRING_NAME_RE = | |
| /^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u; | |
| const UI_MODEL_CLASS_FIELDS = new Map<string, ReadonlySet<string>>([ | |
| ["CommandItem", new Set(["subtitle", "title"])], | |
| ["HomeAttentionRow", new Set(["subtitle", "title"])], | |
| ["OverviewMetricCardSpec", new Set(["subtitle", "title", "value"])], | |
| ["SettingsToggleRow", new Set(["subtitle", "title"])], | |
| ]); | |
| const KOTLIN_STRING_LITERAL_RE = /"""([\s\S]*?)"""|"((?:\\.|[^"\\])+?)"/gu; | |
| // 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", | |
| "Google Chat", | |
| "ID", | |
| "ISO time, e.g. 2026-07-09T09:30:00Z", | |
| "LOG", | |
| "O", | |
| "OC", | |
| "OK", | |
| "OPENCLAW", | |
| "OpenClaw", | |
| "U", | |
| "e.g. America/New_York", | |
| "current-step-alpha", | |
| "gateway-progress", | |
| "iMessage", | |
| "main, isolated, current, or session:<id>", | |
| "n/a", | |
| "openclaw gateway", | |
| "openclaw qr", | |
| "PTT_BUSY: previous push-to-talk turn is still finishing", | |
| "WhatsApp", | |
| ]), | |
| ], | |
| ["apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", new Set(["Off"])], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt", | |
| new Set(["all", "applied", "held", "pending", "rejected"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt", | |
| new Set(["$versionName-dev"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", | |
| // Plan checklist chrome: numeric done-counter and checkmark glyph. | |
| new Set(["$completedCount/${steps.size}", "✓"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", | |
| // Discovered-gateway subtitles are host:port endpoints, not translatable copy. | |
| new Set(["${endpoint.host}:${endpoint.port}"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt", | |
| // Compose animation labels are tooling identifiers, not rendered copy. | |
| new Set(["sidebar-content-translation"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt", | |
| new Set(["/$name", "help"]), | |
| ], | |
| [ | |
| "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt", | |
| new Set([">", "> $line"]), | |
| ], | |
| [ | |
| "apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt", | |
| // Compose animation labels are tooling identifiers, not rendered copy. | |
| new Set(["voice-swipe-hint", "voice-swipe-hint-offset"]), | |
| ], | |
| ]); | |
| function isAllowedUiLiteral(repoPath: string, source: string): boolean { | |
| return ( | |
| source.trim().length === 0 || | |
| 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") || | |
| repoPath.endsWith("/WearScreenshotMode.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("/accessibility/AccessibilityDevActivity.kt") || | |
| repoPath.includes("/wear/src/main/java/ai/openclaw/wear/") || | |
| 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") | |
| ); | |
| } | |
| function findClosingDelimiter( | |
| source: string, | |
| openingOffset: number, | |
| opening: string, | |
| closing: string, | |
| ): number | null { | |
| let depth = 0; | |
| let quoted = false; | |
| let escaped = false; | |
| for (let index = openingOffset; index < source.length; index += 1) { | |
| const character = source[index]; | |
| if (escaped) { | |
| escaped = false; | |
| continue; | |
| } | |
| if (quoted && character === "\\") { | |
| escaped = true; | |
| continue; | |
| } | |
| if (character === '"') { | |
| quoted = !quoted; | |
| continue; | |
| } | |
| if (quoted) { | |
| continue; | |
| } | |
| if (character === opening) { | |
| depth += 1; | |
| } else if (character === closing) { | |
| depth -= 1; | |
| if (depth === 0) { | |
| return index; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function expressionEnd(source: string, expressionStart: number): number { | |
| const cursor = source.slice(expressionStart).search(/\S/u); | |
| if (cursor < 0) { | |
| return source.length; | |
| } | |
| const start = expressionStart + cursor; | |
| let quoted = false; | |
| let escaped = false; | |
| let lineStart = start; | |
| const depths = { "(": 0, "[": 0, "{": 0 }; | |
| const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const; | |
| for (let index = start; index < source.length; index += 1) { | |
| const character = source[index]; | |
| if (escaped) { | |
| escaped = false; | |
| continue; | |
| } | |
| if (quoted && character === "\\") { | |
| escaped = true; | |
| continue; | |
| } | |
| if (character === '"') { | |
| quoted = !quoted; | |
| continue; | |
| } | |
| if (quoted) { | |
| continue; | |
| } | |
| if (character === "(" || character === "[" || character === "{") { | |
| depths[character] += 1; | |
| continue; | |
| } | |
| if (character === ")" || character === "]" || character === "}") { | |
| depths[closingToOpening[character]] -= 1; | |
| continue; | |
| } | |
| if (character !== "\n" || !Object.values(depths).every((depth) => depth === 0)) { | |
| continue; | |
| } | |
| const line = source.slice(lineStart, index).trimEnd(); | |
| const nextLine = | |
| source | |
| .slice(index + 1) | |
| .match(/^[^\S\n]*([^\n]*)/u)?.[1] | |
| ?.trimStart() ?? ""; | |
| const continues = | |
| line.length === 0 || | |
| /(?:\?:|[+*/%&|=.,([{])$/u.test(line) || | |
| /^(?:else\b|\?:|[+*/%&|.])/u.test(nextLine); | |
| if (!continues) { | |
| return index; | |
| } | |
| lineStart = index + 1; | |
| } | |
| return source.length; | |
| } | |
| function splitTopLevelSegments( | |
| source: string, | |
| start: number, | |
| end: number, | |
| options: { trackTypeArguments?: boolean } = {}, | |
| ): Array<{ end: number; start: number; value: string }> { | |
| const segments: Array<{ end: number; start: number; value: string }> = []; | |
| let segmentStart = start; | |
| let quoted = false; | |
| let escaped = false; | |
| let typeArgumentDepth = 0; | |
| let inDefaultValue = false; | |
| const depths = { "(": 0, "[": 0, "{": 0 }; | |
| const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const; | |
| for (let index = start; index < end; index += 1) { | |
| const character = source[index]; | |
| if (escaped) { | |
| escaped = false; | |
| continue; | |
| } | |
| if (quoted && character === "\\") { | |
| escaped = true; | |
| continue; | |
| } | |
| if (character === '"') { | |
| quoted = !quoted; | |
| continue; | |
| } | |
| if (quoted) { | |
| continue; | |
| } | |
| if (options.trackTypeArguments && !inDefaultValue && character === "<") { | |
| typeArgumentDepth += 1; | |
| continue; | |
| } | |
| if (options.trackTypeArguments && character === ">" && typeArgumentDepth > 0) { | |
| typeArgumentDepth -= 1; | |
| continue; | |
| } | |
| if (character === "(" || character === "[" || character === "{") { | |
| depths[character] += 1; | |
| continue; | |
| } | |
| if (character === ")" || character === "]" || character === "}") { | |
| depths[closingToOpening[character]] -= 1; | |
| continue; | |
| } | |
| if ( | |
| options.trackTypeArguments && | |
| character === "=" && | |
| typeArgumentDepth === 0 && | |
| Object.values(depths).every((depth) => depth === 0) | |
| ) { | |
| inDefaultValue = true; | |
| continue; | |
| } | |
| if ( | |
| character === "," && | |
| typeArgumentDepth === 0 && | |
| Object.values(depths).every((depth) => depth === 0) | |
| ) { | |
| segments.push({ end: index, start: segmentStart, value: source.slice(segmentStart, index) }); | |
| segmentStart = index + 1; | |
| inDefaultValue = false; | |
| } | |
| } | |
| segments.push({ end, start: segmentStart, value: source.slice(segmentStart, end) }); | |
| return segments; | |
| } | |
| function isLocalizedLiteral(expression: string, literalOffset: number): boolean { | |
| return /\bnativeString(?:Resource)?\(\s*$/u.test(expression.slice(0, literalOffset)); | |
| } | |
| function isComparisonLiteral(expression: string, literalOffset: number): boolean { | |
| return /(?:==|!=)\s*$/u.test(expression.slice(0, literalOffset)); | |
| } | |
| function collectHelperLiteralFindings(source: string, repoPath: string): AndroidUiLiteralFinding[] { | |
| const findings: AndroidUiLiteralFinding[] = []; | |
| const collectRange = (name: string, start: number, end: number) => { | |
| if (!UI_STRING_HELPER_NAME_RE.test(name)) { | |
| return; | |
| } | |
| const body = source.slice(start, end); | |
| const direct = body.match(/^\s*"((?:\\.|[^"\\])+)"/u); | |
| const directRaw = body.match(/^\s*"""([\s\S]*?)"""/u); | |
| const matches = [ | |
| ...(direct ? [direct] : []), | |
| ...(directRaw ? [directRaw] : []), | |
| ...body.matchAll(UI_BRANCH_LITERAL_RE), | |
| ...body.matchAll(UI_ELSE_BRANCH_LITERAL_RE), | |
| ...body.matchAll(UI_BRANCH_RAW_LITERAL_RE), | |
| ...body.matchAll(UI_ELSE_BRANCH_RAW_LITERAL_RE), | |
| ]; | |
| const seenOffsets = new Set<number>(); | |
| const addLiteral = (literal: string, literalOffset: number) => { | |
| if (seenOffsets.has(literalOffset)) { | |
| return; | |
| } | |
| seenOffsets.add(literalOffset); | |
| findings.push({ | |
| line: lineNumber(source, start + literalOffset), | |
| path: repoPath, | |
| source: decodeKotlinLiteral(literal), | |
| }); | |
| }; | |
| for (const match of matches) { | |
| const literal = match[1]; | |
| if (!literal) { | |
| continue; | |
| } | |
| const delimiter = match[0]?.includes('"""') ? '"""' : '"'; | |
| const literalOffset = body.indexOf(`${delimiter}${literal}${delimiter}`, match.index ?? 0); | |
| addLiteral(literal, Math.max(0, literalOffset)); | |
| } | |
| for (const match of body.matchAll(/\bif\s*/gu)) { | |
| let cursor = (match.index ?? 0) + match[0].length; | |
| if (body[cursor] !== "(") { | |
| continue; | |
| } | |
| const closingParen = findClosingDelimiter(body, cursor, "(", ")"); | |
| if (closingParen === null) { | |
| continue; | |
| } | |
| cursor = closingParen + 1; | |
| while (/\s/u.test(body[cursor] ?? "")) { | |
| cursor += 1; | |
| } | |
| if (body[cursor] === "{") { | |
| cursor += 1; | |
| while (/\s/u.test(body[cursor] ?? "")) { | |
| cursor += 1; | |
| } | |
| } | |
| const expression = body.slice(cursor); | |
| const literal = | |
| expression.match(/^"""([\s\S]*?)"""/u)?.[1] ?? | |
| expression.match(/^"((?:\\.|[^"\\])+)"/u)?.[1]; | |
| if (literal) { | |
| addLiteral(literal, cursor); | |
| } | |
| } | |
| }; | |
| for (const match of source.matchAll(UI_STRING_FUNCTION_RE)) { | |
| const name = match[1]; | |
| if (!name) { | |
| continue; | |
| } | |
| let openingParen = (match.index ?? 0) + match[0].length; | |
| while (/\s/u.test(source[openingParen] ?? "")) { | |
| openingParen += 1; | |
| } | |
| if (source[openingParen] !== "(") { | |
| continue; | |
| } | |
| const closingParen = findClosingDelimiter(source, openingParen, "(", ")"); | |
| if (closingParen === null) { | |
| continue; | |
| } | |
| const returnType = source.slice(closingParen + 1).match(/^\s*:\s*String\s*(=|\{)/u); | |
| const bodyKind = returnType?.[1]; | |
| if (!bodyKind || returnType?.index === undefined) { | |
| continue; | |
| } | |
| const bodyStart = closingParen + 1 + returnType.index + returnType[0].length; | |
| if (bodyKind === "{") { | |
| const openingBrace = bodyStart - 1; | |
| const closingBrace = findClosingDelimiter(source, openingBrace, "{", "}"); | |
| if (closingBrace !== null) { | |
| collectRange(name, bodyStart, closingBrace); | |
| } | |
| } else { | |
| collectRange(name, bodyStart, expressionEnd(source, bodyStart)); | |
| } | |
| } | |
| for (const match of source.matchAll(UI_STRING_PROPERTY_RE)) { | |
| const name = match[1]; | |
| if (!name) { | |
| continue; | |
| } | |
| const bodyStart = (match.index ?? 0) + match[0].length; | |
| collectRange(name, bodyStart, expressionEnd(source, bodyStart)); | |
| } | |
| return findings; | |
| } | |
| function collectTypedModelLiteralFindings( | |
| source: string, | |
| repoPath: string, | |
| ): AndroidUiLiteralFinding[] { | |
| const findings: AndroidUiLiteralFinding[] = []; | |
| const classPattern = /\b(?:data\s+class|class)\s+([A-Za-z_][A-Za-z0-9_]*)\s*/gu; | |
| for (const declaration of source.matchAll(classPattern)) { | |
| const className = declaration[1]; | |
| if (!className) { | |
| continue; | |
| } | |
| let openingParen = (declaration.index ?? 0) + declaration[0].length; | |
| while (/\s/u.test(source[openingParen] ?? "")) { | |
| openingParen += 1; | |
| } | |
| if (source[openingParen] === "<") { | |
| const closingTypeArguments = findClosingDelimiter(source, openingParen, "<", ">"); | |
| if (closingTypeArguments === null) { | |
| continue; | |
| } | |
| openingParen = closingTypeArguments + 1; | |
| while (/\s/u.test(source[openingParen] ?? "")) { | |
| openingParen += 1; | |
| } | |
| } | |
| if (source[openingParen] !== "(") { | |
| continue; | |
| } | |
| const closingParen = findClosingDelimiter(source, openingParen, "(", ")"); | |
| if (closingParen === null) { | |
| continue; | |
| } | |
| const parameters = splitTopLevelSegments(source, openingParen + 1, closingParen, { | |
| trackTypeArguments: true, | |
| }); | |
| const userFacingParameters = new Map<string, number>(); | |
| const classFields = UI_MODEL_CLASS_FIELDS.get(className); | |
| parameters.forEach((parameter, index) => { | |
| const field = parameter.value.match( | |
| /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\??(?=\s*(?:=|$))/u, | |
| )?.[1]; | |
| if (field && (UI_MODEL_STRING_NAME_RE.test(field) || classFields?.has(field))) { | |
| userFacingParameters.set(field, index); | |
| } | |
| }); | |
| if (userFacingParameters.size === 0) { | |
| continue; | |
| } | |
| const callPattern = new RegExp(`\\b${className}\\b`, "gu"); | |
| for (const call of source.matchAll(callPattern)) { | |
| let callOpening = (call.index ?? 0) + call[0].length; | |
| while (/\s/u.test(source[callOpening] ?? "")) { | |
| callOpening += 1; | |
| } | |
| if (source[callOpening] === "<") { | |
| const closingTypeArguments = findClosingDelimiter(source, callOpening, "<", ">"); | |
| if (closingTypeArguments === null) { | |
| continue; | |
| } | |
| callOpening = closingTypeArguments + 1; | |
| while (/\s/u.test(source[callOpening] ?? "")) { | |
| callOpening += 1; | |
| } | |
| } | |
| if (source[callOpening] !== "(") { | |
| continue; | |
| } | |
| if (callOpening === openingParen) { | |
| continue; | |
| } | |
| const callClosing = findClosingDelimiter(source, callOpening, "(", ")"); | |
| if (callClosing === null) { | |
| continue; | |
| } | |
| const argumentsList = splitTopLevelSegments(source, callOpening + 1, callClosing); | |
| const namedArguments = new Map<string, (typeof argumentsList)[number]>(); | |
| let positionalArgumentCount = argumentsList.length; | |
| argumentsList.forEach((argument, index) => { | |
| const name = argument.value.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/u)?.[1]; | |
| if (!name) { | |
| return; | |
| } | |
| namedArguments.set(name, argument); | |
| positionalArgumentCount = Math.min(positionalArgumentCount, index); | |
| }); | |
| for (const [field, parameterIndex] of userFacingParameters) { | |
| const argument = | |
| namedArguments.get(field) ?? | |
| (parameterIndex < positionalArgumentCount ? argumentsList[parameterIndex] : undefined); | |
| if (!argument) { | |
| continue; | |
| } | |
| for (const literal of argument.value.matchAll(KOTLIN_STRING_LITERAL_RE)) { | |
| const literalValue = literal[1] ?? literal[2]; | |
| if ( | |
| !literalValue || | |
| isLocalizedLiteral(argument.value, literal.index ?? 0) || | |
| isComparisonLiteral(argument.value, literal.index ?? 0) | |
| ) { | |
| continue; | |
| } | |
| findings.push({ | |
| line: lineNumber(source, argument.start + (literal.index ?? 0)), | |
| path: repoPath, | |
| source: literal[1] === undefined ? decodeKotlinLiteral(literalValue) : literalValue, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| return findings; | |
| } | |
| export function findUnlocalizedAndroidUiLiterals( | |
| source: string, | |
| repoPath: string, | |
| ): AndroidUiLiteralFinding[] { | |
| if (!shouldScanUiLiterals(repoPath)) { | |
| return []; | |
| } | |
| const findings = new Map<string, AndroidUiLiteralFinding>(); | |
| for (const [patterns, delimiter] of [ | |
| [DIRECT_UI_LITERAL_PATTERNS, '"'], | |
| [DIRECT_UI_RAW_LITERAL_PATTERNS, '"""'], | |
| ] as const) { | |
| for (const pattern of patterns) { | |
| pattern.lastIndex = 0; | |
| for (const match of source.matchAll(pattern)) { | |
| const literal = match[1]; | |
| if (!literal) { | |
| continue; | |
| } | |
| const findingSource = delimiter === '"' ? decodeKotlinLiteral(literal) : literal; | |
| if ( | |
| isAllowedUiLiteral(repoPath, findingSource) || | |
| literal.startsWith("http") || | |
| literal.startsWith("content://") | |
| ) { | |
| continue; | |
| } | |
| const matchText = match[0] ?? ""; | |
| const offset = | |
| (match.index ?? 0) + matchText.lastIndexOf(`${delimiter}${literal}${delimiter}`); | |
| const finding = { | |
| line: lineNumber(source, Math.max(0, offset)), | |
| path: repoPath, | |
| source: findingSource, | |
| }; | |
| findings.set(`${finding.line}\u0000${finding.source}`, finding); | |
| } | |
| } | |
| } | |
| for (const finding of [ | |
| ...collectHelperLiteralFindings(source, repoPath), | |
| ...collectTypedModelLiteralFindings(source, repoPath), | |
| ]) { | |
| if (!isAllowedUiLiteral(repoPath, finding.source)) { | |
| 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: ReadonlyMap<string, ResourceString>): string[] { | |
| return [...strings] | |
| .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(source: string, values: readonly string[]): string { | |
| const translated = values.filter((value) => value !== source); | |
| const candidates = translated.length > 0 ? translated : values; | |
| const counts = new Map<string, number>(); | |
| for (const value of candidates) { | |
| counts.set(value, (counts.get(value) ?? 0) + 1); | |
| } | |
| return ( | |
| [...counts] | |
| .toSorted( | |
| ([left, leftCount], [right, rightCount]) => | |
| rightCount - leftCount || compareText(left, right), | |
| ) | |
| .at(0)?.[0] ?? "" | |
| ); | |
| } | |
| export function selectGeneratedTranslation( | |
| source: string, | |
| artifactTranslations: readonly string[], | |
| existing?: { source: string; translation: string }, | |
| ): string { | |
| // Tool-display sources can remain live after their matching UI inventory entries are retired. | |
| // Preserve the checked-in locale value only while its English source is unchanged. | |
| const candidates = | |
| artifactTranslations.length > 0 | |
| ? artifactTranslations | |
| : existing?.source === source && existing.translation !== source | |
| ? [existing.translation] | |
| : []; | |
| return selectDeterministicTranslation(source, candidates); | |
| } | |
| 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, NativeTranslations>> { | |
| 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 { translations?: NativeTranslations }; | |
| return [locale, parsed.translations ?? {}] as const; | |
| }), | |
| ), | |
| ); | |
| } | |
| function translationsBySource( | |
| inventory: readonly NativeInventoryEntry[], | |
| translationsById: Readonly<NativeTranslations>, | |
| ): Map<string, string[]> { | |
| const translations = new Map<string, string[]>(); | |
| for (const entry of inventory) { | |
| const translated = translationsById[entry.id]; | |
| if (translated !== undefined) { | |
| translations.set(entry.source, [translated]); | |
| } | |
| } | |
| return translations; | |
| } | |
| function localizeManualStrings( | |
| base: ReadonlyMap<string, ResourceString>, | |
| inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>, | |
| translations: Readonly<NativeTranslations>, | |
| surface: string, | |
| ): ResourceString[] { | |
| return [...base.values()].map((entry) => { | |
| const source = decodeAndroidResourceValue(entry.rawValue); | |
| const translatable = !/\btranslatable\s*=\s*"false"/u.test(entry.attrs); | |
| const inventoryEntry = inventoryBySource.get(source); | |
| if (translatable && !inventoryEntry) { | |
| throw new Error( | |
| `${surface} string is missing from native inventory: ${JSON.stringify(source)}`, | |
| ); | |
| } | |
| const value = (translatable && inventoryEntry && translations[inventoryEntry.id]) || source; | |
| return { | |
| attrs: translatable ? withGeneratedTranslationLintIgnores(entry.attrs) : entry.attrs, | |
| key: entry.key, | |
| rawValue: `"${escapeAndroidResourceValue(value)}"`, | |
| value, | |
| }; | |
| }); | |
| } | |
| function renderStringsXml( | |
| manual: readonly ResourceString[], | |
| generated: ReadonlyMap<string, { source: string; value: string }>, | |
| ): string { | |
| const usesToolsNamespace = | |
| generated.size > 0 || manual.some((entry) => entry.attrs.includes("tools:")); | |
| const lines = [ | |
| usesToolsNamespace | |
| ? '<resources xmlns:tools="http://schemas.android.com/tools">' | |
| : "<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 = | |
| (readKotlinInterpolations(entry.source)?.length ?? 0) > 0 ? "" : ' formatted="false"'; | |
| // Translation-memory text intentionally preserves technical tokens and source punctuation, | |
| // so Android's English dictionary and typography suggestions do not apply to managed keys. | |
| lines.push( | |
| ` <string name="${key}"${withGeneratedTranslationLintIgnores(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"); | |
| } | |
| export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> { | |
| const inventory = await readInventory(); | |
| const [ | |
| artifacts, | |
| localeStrings, | |
| thirdPartyBaseStrings, | |
| wearBaseStrings, | |
| sourceFiles, | |
| toolDisplaySources, | |
| ] = await Promise.all([ | |
| readArtifacts(), | |
| Promise.all(LOCALES.map((locale) => readStrings(locale))), | |
| readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE), | |
| readStrings("values", WEAR_RESOURCE_ROOT), | |
| readAllAndroidSource(), | |
| readToolDisplaySources(), | |
| ]); | |
| const baseStrings = expectDefined(localeStrings[0], "English Android string resources"); | |
| const translatedStrings = localeStrings.slice(1); | |
| const wearInventoryBySource = new Map( | |
| inventory | |
| .filter((entry) => entry.sites.some((site) => site.path === WEAR_STRINGS_REPO_PATH)) | |
| .map((entry) => [entry.source, entry]), | |
| ); | |
| const thirdPartyInventoryBySource = new Map( | |
| inventory | |
| .filter((entry) => entry.sites.some((site) => site.path === THIRD_PARTY_STRINGS_REPO_PATH)) | |
| .map((entry) => [entry.source, entry]), | |
| ); | |
| 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) { | |
| if ( | |
| entry.surface !== "android" || | |
| entry.sites.every((site) => site.kind === "resource-string") | |
| ) { | |
| continue; | |
| } | |
| 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 localeTranslations = artifacts.get(locale) ?? {}; | |
| const artifactTranslationsBySource = translationsBySource(inventory, localeTranslations); | |
| 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 existingSource = decodeAndroidResourceValue(baseStrings.get(key)?.rawValue ?? ""); | |
| const existingTranslation = decodeAndroidResourceValue( | |
| manualTranslations.get(key)?.rawValue ?? "", | |
| ); | |
| const selected = selectGeneratedTranslation( | |
| source, | |
| translations, | |
| existingSource && existingTranslation | |
| ? { source: existingSource, translation: existingTranslation } | |
| : undefined, | |
| ); | |
| if (selected === source && translations.some((translation) => translation !== source)) { | |
| throw new Error( | |
| `Android translation selection kept the source despite a translated candidate: ${locale} ${JSON.stringify(source)}`, | |
| ); | |
| } | |
| 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 wearManual = localizeManualStrings( | |
| wearBaseStrings, | |
| wearInventoryBySource, | |
| localeTranslations, | |
| "Wear", | |
| ); | |
| resources.set( | |
| path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"), | |
| renderStringsXml(wearManual, new Map()), | |
| ); | |
| const thirdPartyManual = localizeManualStrings( | |
| thirdPartyBaseStrings, | |
| thirdPartyInventoryBySource, | |
| localeTranslations, | |
| "Android third-party", | |
| ); | |
| resources.set( | |
| path.join( | |
| ANDROID_THIRD_PARTY_RESOURCE_ROOT, | |
| localeDirectory(locale), | |
| THIRD_PARTY_STRINGS_FILE, | |
| ), | |
| renderStringsXml(thirdPartyManual, new Map()), | |
| ); | |
| } | |
| 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, localeTranslations] of artifacts) { | |
| const translatedBySource = translationsBySource(inventory, localeTranslations); | |
| const translatedItems = assistantItems.map( | |
| (source) => | |
| selectDeterministicTranslation(source, 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 i18n resources are out of sync.", | |
| ...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`), | |
| ].join("\n"); | |
| } | |
| /** | |
| * Managed app rows (native_*) and localized Wear catalogs are owned end-to-end | |
| * by the post-merge locale refresh workflow, so a source PR may legitimately | |
| * carry stale generated output. Every other delta is real drift. | |
| */ | |
| function onlyManagedRowsPending(current: string, expected: string): boolean { | |
| const currentRows = new Map(parseStrings(current).map((entry) => [entry.key, entry])); | |
| const expectedRows = new Map(parseStrings(expected).map((entry) => [entry.key, entry])); | |
| for (const [key, entry] of currentRows) { | |
| const expectedEntry = expectedRows.get(key); | |
| if (!expectedEntry) { | |
| if (!key.startsWith(MANAGED_PREFIX)) { | |
| return false; | |
| } | |
| continue; | |
| } | |
| if (expectedEntry.attrs !== entry.attrs || expectedEntry.rawValue !== entry.rawValue) { | |
| return false; | |
| } | |
| } | |
| for (const key of expectedRows.keys()) { | |
| if (!currentRows.has(key) && !key.startsWith(MANAGED_PREFIX)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| export async function syncAndroidAppI18n( | |
| options: { check?: boolean; tolerateManagedPending?: boolean } = {}, | |
| ) { | |
| const catalog = await buildAndroidAppI18nCatalog(); | |
| const drift: string[] = []; | |
| let sawUnmanagedDrift = false; | |
| for (const [filePath, expected] of catalog.resources) { | |
| const current = await readFile(filePath, "utf8").catch(() => ""); | |
| if (current === expected) { | |
| continue; | |
| } | |
| const relativeFilePath = path.relative(ROOT, filePath).split(path.sep).join("/"); | |
| if ( | |
| options.check && | |
| options.tolerateManagedPending && | |
| (WEAR_GENERATED_RESOURCE_RE.test(relativeFilePath) || | |
| THIRD_PARTY_GENERATED_RESOURCE_RE.test(relativeFilePath) || | |
| (filePath.endsWith("strings.xml") && onlyManagedRowsPending(current, expected))) | |
| ) { | |
| continue; | |
| } | |
| sawUnmanagedDrift = true; | |
| drift.push(relativeFilePath); | |
| if (!options.check) { | |
| await mkdir(path.dirname(filePath), { recursive: true }); | |
| await writeFile(filePath, expected); | |
| } | |
| } | |
| const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => ""); | |
| if (currentKotlin !== catalog.kotlin) { | |
| // The Kotlin map derives 1:1 from the same managed catalog: tolerate it | |
| // exactly when every resource delta above was managed-pending. | |
| if (!(options.check && options.tolerateManagedPending && !sawUnmanagedDrift)) { | |
| 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((translation) => JSON.stringify(translation)).join(", ")})`, | |
| ), | |
| ...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []), | |
| "", | |
| ].join("\n"), | |
| ); | |
| } | |
| return catalog; | |
| } | |
| export async function verifyAndroidAppI18n() { | |
| const [ | |
| sourceFiles, | |
| base, | |
| referenceSource, | |
| thirdPartyBase, | |
| thirdPartyReferenceSource, | |
| wearBase, | |
| wearReferenceSource, | |
| ] = await Promise.all([ | |
| readAllAndroidSource(), | |
| readStrings("values"), | |
| readAndroidResourceReferences(), | |
| readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE), | |
| readAndroidResourceReferences(ANDROID_THIRD_PARTY_ROOT), | |
| readStrings("values", WEAR_RESOURCE_ROOT), | |
| readAndroidResourceReferences(ANDROID_WEAR_MAIN_ROOT), | |
| ]); | |
| const baseKeys = new Set(base.keys()); | |
| const thirdPartyBaseKeys = new Set(thirdPartyBase.keys()); | |
| const wearBaseKeys = new Set(wearBase.keys()); | |
| const problems: Array<readonly [string, string[]]> = [ | |
| ["App English syntax", findInvalidResourceSyntax(base)], | |
| ["Third-party English syntax", findInvalidResourceSyntax(thirdPartyBase)], | |
| ["Wear English syntax", findInvalidResourceSyntax(wearBase)], | |
| ]; | |
| const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX)); | |
| problems.push([ | |
| "App English unused", | |
| findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource), | |
| ]); | |
| problems.push([ | |
| "Third-party English unused", | |
| findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource), | |
| ]); | |
| problems.push([ | |
| "Wear English unused", | |
| findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource), | |
| ]); | |
| const uiFindings = sourceFiles.flatMap((file) => | |
| findUnlocalizedAndroidUiLiterals(file.source, file.path), | |
| ); | |
| problems.push([ | |
| "Unlocalized UI literals", | |
| uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`), | |
| ]); | |
| if (problems.some(([, keys]) => keys.length)) { | |
| throw new Error(formatProblems(problems)); | |
| } | |
| process.stdout.write( | |
| `android-app-i18n: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`, | |
| ); | |
| } | |
| export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) { | |
| await verifyAndroidAppI18n(); | |
| await syncAndroidAppI18n({ check: true, ...options }); | |
| const localeStrings = await Promise.all(LOCALES.map((locale) => readStrings(locale))); | |
| const wearLocaleStrings = options.tolerateManagedPending | |
| ? [] | |
| : await Promise.all(LOCALES.map((locale) => readStrings(locale, WEAR_RESOURCE_ROOT))); | |
| const thirdPartyLocaleStrings = options.tolerateManagedPending | |
| ? [] | |
| : await Promise.all( | |
| LOCALES.map((locale) => | |
| readStrings(locale, ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE), | |
| ), | |
| ); | |
| const base = expectDefined(localeStrings[0], "English Android string resources"); | |
| const localeProblems = ( | |
| surface: string, | |
| surfaceBase: ReadonlyMap<string, ResourceString>, | |
| translations: ReadonlyArray<ReadonlyMap<string, ResourceString>>, | |
| ): Array<readonly [string, string[]]> => { | |
| const surfaceBaseKeys = new Set(surfaceBase.keys()); | |
| return translations.flatMap((strings, index) => { | |
| const locale = NATIVE_I18N_LOCALES[index]; | |
| const keys = new Set(strings.keys()); | |
| const placeholderMismatches = [...surfaceBase].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 [ | |
| [`${surface} ${locale} missing`, [...surfaceBaseKeys].filter((key) => !keys.has(key))], | |
| [`${surface} ${locale} extra`, [...keys].filter((key) => !surfaceBaseKeys.has(key))], | |
| [`${surface} ${locale} placeholders`, placeholderMismatches], | |
| [`${surface} ${locale} syntax`, findInvalidResourceSyntax(strings)], | |
| ] as const; | |
| }); | |
| }; | |
| const problems = localeProblems("App", base, localeStrings.slice(1)); | |
| if (thirdPartyLocaleStrings.length > 0) { | |
| const thirdPartyBase = expectDefined( | |
| thirdPartyLocaleStrings[0], | |
| "English Android third-party string resources", | |
| ); | |
| problems.push( | |
| ...localeProblems("Third-party", thirdPartyBase, thirdPartyLocaleStrings.slice(1)), | |
| ); | |
| } | |
| if (wearLocaleStrings.length > 0) { | |
| const wearBase = expectDefined(wearLocaleStrings[0], "English Wear string resources"); | |
| problems.push(...localeProblems("Wear", wearBase, wearLocaleStrings.slice(1))); | |
| } | |
| if (problems.some(([, keys]) => keys.length)) { | |
| throw new Error(formatProblems(problems)); | |
| } | |
| process.stdout.write( | |
| `android-app-i18n: appKeys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`, | |
| ); | |
| } | |
| if (isDirectRunUrl(process.argv[1], import.meta.url)) { | |
| const [command] = process.argv.slice(2); | |
| 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>"); | |
| } | |
| } | |