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]); 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)\s*=\s*"((?:\\.|[^"\\])+)"/gu, /\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"((?:\\.|[^"\\])+)"/gu, /\bSettingsMetric\s*\(\s*"((?:\\.|[^"\\])+)"/gu, /\bToast\.makeText\s*\([^,]+,\s*"((?:\\.|[^"\\])+)"/gu, /\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"((?:\\.|[^"\\])+)"/gu, ] as const; // These literals are either technical data or immutable source tokens localized at a typed render edge. const ALLOWED_UI_LITERALS = new Map>([ [ "*", new Set([ "0 = exact", "Cron expression, e.g. 0 9 * * *", "D", "ID", "ISO time, e.g. 2026-07-09T09:30:00Z", "OC", "OPENCLAW", "e.g. America/New_York", "current-step-alpha", "gateway-progress", "main, isolated, current, or session:", "openclaw gateway", "openclaw qr", "PTT_BUSY: previous push-to-talk turn is still finishing", ]), ], [ "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", new Set([ "Connected", "Continue", "Checking approval…", "Connecting Gateway", "Connection issue", "Go back", "I have approved", "Node Approval Pending", "Pairing Gateway", "Retry connection", "Still connecting", ]), ], [ "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", new Set(["Chat", "Files", "Home", "Providers", "Sessions", "Settings", "Voice"]), ], [ "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", new Set([ "Ask OpenClaw to use Android capabilities.", "Catch me up", "Plan the work", "Summarize recent sessions and next steps.", "Turn a goal into an actionable checklist.", "Use this phone", ]), ], ]); function isAllowedUiLiteral(repoPath: string, source: string): boolean { return ( ALLOWED_UI_LITERALS.get("*")?.has(source) === true || ALLOWED_UI_LITERALS.get(repoPath)?.has(source) === true ); } function shouldScanUiLiterals(repoPath: string): boolean { if (repoPath.endsWith("/i18n/NativeStringResources.kt")) return false; if (repoPath.endsWith("/AndroidScreenshotFixture.kt")) return false; if (repoPath.endsWith("/ui/design/ClawComponents.kt")) return false; if (repoPath.endsWith("/ui/design/OpenClawMascot.kt")) return false; return ( repoPath.includes("/ui/") || repoPath.endsWith("/MainActivity.kt") || repoPath.endsWith("/NodeRuntime.kt") || repoPath.endsWith("/PermissionRequester.kt") || repoPath.endsWith("/NodeForegroundService.kt") || repoPath.endsWith("/chat/ChatController.kt") || repoPath.endsWith("/voice/MicCaptureManager.kt") || repoPath.endsWith("/voice/TalkModeManager.kt") || repoPath.endsWith("/node/SystemHandler.kt") ); } export function findUnlocalizedAndroidUiLiterals( source: string, repoPath: string, ): AndroidUiLiteralFinding[] { if (!shouldScanUiLiterals(repoPath)) return []; const findings = new Map(); for (const pattern of DIRECT_UI_LITERAL_PATTERNS) { pattern.lastIndex = 0; for (const match of source.matchAll(pattern)) { const literal = match[1]; if (!literal) continue; const matchText = match[0] ?? ""; if ( matchText.includes("nativeStringResource(") || matchText.includes("nativeString(") || isAllowedUiLiteral(repoPath, decodeKotlinLiteral(literal)) || literal.startsWith("http") || literal.startsWith("content://") ) { continue; } const offset = (match.index ?? 0) + matchText.lastIndexOf(`"${literal}"`); const finding = { line: lineNumber(source, Math.max(0, offset)), path: repoPath, source: decodeKotlinLiteral(literal), }; findings.set(`${finding.line}\u0000${finding.source}`, finding); } } return [...findings.values()].toSorted( (left, right) => left.line - right.line || compareText(left.source, right.source), ); } function findInvalidResourceSyntax(strings: Map): string[] { 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 "); } }