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"; import { NATIVE_I18N_LOCALES } from "./native-app-i18n.ts"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); 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 = { 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 = /]*)>([\s\S]*?)<\/string>/gu; const ARRAY_RE = /]*>([\s\S]*?)<\/string-array>/gu; const ARRAY_ITEM_RE = /([\s\S]*?)<\/item>/gu; const FORMAT_RE = /%\d+\$[a-z]/giu; const INVALID_APOSTROPHE_RE = /(?:'|(?"; const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit."; 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; sources: Set; }; 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 renderAndroidResourceValue(source: string, translated: string): string { let rendered = translated; const sourceTokens = [...source.matchAll(INTERPOLATION_RE)].map((match) => match[0]); const translatedTokens = [...translated.matchAll(INTERPOLATION_RE)].map((match) => match[0]); const tokenCounts = (tokens: readonly string[]) => { const counts = new Map(); 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) { rendered = rendered.replaceAll("%", "%%"); const sourceIndices = new Map(); for (const [index, token] of sourceTokens.entries()) { const indices = sourceIndices.get(token) ?? []; indices.push(index + 1); sourceIndices.set(token, indices); } const translatedOccurrences = new Map(); 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("&", "&") .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 parseArrays(source: string): Map { 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): Promise> { 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> { 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; } 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 { const sources = new Set(); 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()): Set { 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> { 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 UI_STRING_HELPER_NAME_RE = /(?:description|detail|error|label|message|nextRun|notice|status|subtitle|summary|text|title)$/iu; const UI_STRING_FUNCTION_RE = /\bfun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*:\s*String\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_IF_BRANCH_LITERAL_RE = /(?:\bif\s*\([^)]*\)|\belse)\s*\{\s*"((?:\\.|[^"\\])+)"/gu; const UI_MODEL_STRING_NAME_RE = /^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u; const KOTLIN_STRING_LITERAL_RE = /"((?:\\.|[^"\\])+)"/gu; // These literals are either technical data or immutable source tokens localized at a typed render edge. const ALLOWED_UI_LITERALS = new Map>([ [ "*", 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:", "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/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", ]), ], [ "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/chat/ChatCommandControls.kt", new Set(["/$name", "help"]), ], ]); 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")) 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") ); } 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 matches = [ ...(direct ? [direct] : []), ...body.matchAll(UI_BRANCH_LITERAL_RE), ...body.matchAll(UI_IF_BRANCH_LITERAL_RE), ]; for (const match of matches) { const literal = match[1]; if (!literal) continue; const literalOffset = body.indexOf(`"${literal}"`, match.index ?? 0); findings.push({ line: lineNumber(source, start + Math.max(0, literalOffset)), path: repoPath, source: decodeKotlinLiteral(literal), }); } }; for (const match of source.matchAll(UI_STRING_FUNCTION_RE)) { const name = match[1]; const bodyKind = match[2]; if (!name || !bodyKind) continue; const bodyStart = (match.index ?? 0) + match[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; const openingParen = (declaration.index ?? 0) + declaration[0].lastIndexOf("("); const closingParen = findClosingDelimiter(source, openingParen, "(", ")"); if (closingParen === null) continue; const parameters = splitTopLevelSegments(source, openingParen + 1, closingParen, { trackTypeArguments: true, }); const userFacingParameters = new Map(); 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)) userFacingParameters.set(field, index); }); if (userFacingParameters.size === 0) continue; const callPattern = new RegExp(`\\b${className}\\s*\\(`, "gu"); for (const call of source.matchAll(callPattern)) { const callOpening = (call.index ?? 0) + call[0].lastIndexOf("("); if (callOpening === openingParen) continue; const callClosing = findClosingDelimiter(source, callOpening, "(", ")"); if (callClosing === null) continue; const argumentsList = splitTopLevelSegments(source, callOpening + 1, callClosing); const namedArguments = new Map(); 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)) { if ( !literal[1] || 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: decodeKotlinLiteral(literal[1]), }); } } } } return findings; } export function findUnlocalizedAndroidUiLiterals( source: string, repoPath: string, ): AndroidUiLiteralFinding[] { if (!shouldScanUiLiterals(repoPath)) return []; const findings = new Map(); 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 ( 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); } } 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: Map): 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(values: readonly string[]): string { const counts = new Map(); 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 { const parsed = JSON.parse(await readFile(INVENTORY_PATH, "utf8")) as { entries?: NativeInventoryEntry[]; }; return (parsed.entries ?? []).filter((entry) => entry.surface === "android"); } async function readArtifacts(): Promise> { 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 { const lines = [""]; for (const entry of manual) { lines.push(` ${entry.rawValue}`); } 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( ` "${renderAndroidResourceValue(entry.source, entry.value)}"`, ); } } lines.push("", ""); return lines.join("\n"); } function renderAssistantXml(items: readonly string[]): string { return [ "", ' ', ...items.map((item) => ` "${renderAndroidResourceValue(item, item)}"`), " ", "", "", ].join("\n"); } function renderKotlin(sourceToKey: ReadonlyMap): 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 =", " mapOf(", ...entries.map(([source, key]) => ` "${escapeKotlin(source)}" to R.string.${key},`), " )", "", ].join("\n"); } async function buildCatalog(): Promise { 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(); 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(); for (const source of entriesBySource.keys()) { sourceToKey.set(source, manualSourceToKey.get(source) ?? resourceKey(source)); } const resources = new Map(); const contradictions: TranslationContradiction[] = []; for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) { const manualTranslations = translatedStrings[localeIndex] ?? new Map(); const artifactTranslationsBySource = new Map(); 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(); 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(); 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(); 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): 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 [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 = translations.flatMap((strings, index) => { const locale = NATIVE_I18N_LOCALES[index]; const keys = new Set(strings.keys()); 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 [ [`${locale} missing`, [...baseKeys].filter((key) => !keys.has(key))], [`${locale} extra`, [...keys].filter((key) => !baseKeys.has(key))], [`${locale} placeholders`, placeholderMismatches], [`${locale} syntax`, findInvalidResourceSyntax(strings)], ] as const; }); problems.push(["English syntax", findInvalidResourceSyntax(base)]); 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([ "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: keys=${baseKeys.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`, ); } if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) { 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 "); } }