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_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 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 ANDROID_REFERENCE_COMMENT_RE = /("""[\s\S]*?"""|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')||\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/gu; const XML_COMMENT_RE = //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; kind: 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("\\\\", "\\"); } 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(); 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(); for (const [index, token] of sourceTokens.entries()) { const indices = sourceIndices.get(token) ?? []; indices.push(index + 1); sourceIndices.set(token, indices); } const translatedOccurrences = new Map(); 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 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; } type AndroidResourceReferenceSource = { path: string; source: string; }; async function readAndroidResourceReferences( root = ANDROID_MAIN_ROOT, ): Promise { 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, referenceSources: readonly AndroidResourceReferenceSource[], ): string[] { const references = new Set(); 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 .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 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>([ ["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>([ [ "*", 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/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/VoiceScreen.kt", new Set(["${normalized.take(87)}..."]), ], [ "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"]), ], ]); 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 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(); 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(); 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(); 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(); 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: 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(source: string, values: readonly string[]): string { const translated = values.filter((value) => value !== source); const candidates = translated.length > 0 ? translated : values; const counts = new Map(); 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] ?? "" ); } 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 = [ generated.size > 0 ? '' : "", ]; 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 = (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( ` "${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 = expectDefined(localeStrings[0], "English Android string resources"); const translatedStrings = localeStrings.slice(1); 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) { if (entry.surface !== "android" || entry.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(); 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(source, translations); 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 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(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): 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((translation) => JSON.stringify(translation)).join(", ")})`, ), ...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []), "", ].join("\n"), ); } return catalog; } export async function checkAndroidAppI18n() { const [sourceFiles, localeStrings, referenceSource] = await Promise.all([ readAndroidSource(), Promise.all(LOCALES.map(readStrings)), readAndroidResourceReferences(), ]); 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 manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX)); problems.push(["English unused", findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource)]); 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 "); } }