fix(i18n): cover Android flavor surfaces

This commit is contained in:
Vincent Koc
2026-07-24 05:36:34 +02:00
parent 9d2d5d111c
commit 4990ac3aa9
4 changed files with 171 additions and 14 deletions
+94 -14
View File
@@ -10,10 +10,19 @@ 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, WEAR_SOURCE_ROOT] as const;
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(
@@ -54,6 +63,11 @@ const FORMAT_RE = /%\d+\$[a-z]/giu;
const INVALID_APOSTROPHE_RE = /(?:&apos;|(?<!\\)')/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$/;
@@ -276,8 +290,9 @@ function parseArrays(source: string): Map<string, string[]> {
async function readStrings(
locale: string,
resourceRoot = RESOURCE_ROOT,
fileName = "strings.xml",
): Promise<Map<string, ResourceString>> {
const source = await readFile(path.join(resourceRoot, locale, "strings.xml"), "utf8");
const source = await readFile(path.join(resourceRoot, locale, fileName), "utf8");
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
}
@@ -543,6 +558,7 @@ function shouldScanUiLiterals(repoPath: string): boolean {
}
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") ||
@@ -1104,13 +1120,16 @@ function localizeManualStrings(
base: ReadonlyMap<string, ResourceString>,
inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>,
artifactEntries: ReadonlyMap<string, NativeArtifactEntry>,
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(`Wear string is missing from native inventory: ${JSON.stringify(source)}`);
throw new Error(
`${surface} string is missing from native inventory: ${JSON.stringify(source)}`,
);
}
const value =
translatable && inventoryEntry
@@ -1185,15 +1204,23 @@ function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
}
export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
const [inventory, artifacts, localeStrings, wearBaseStrings, sourceFiles, toolDisplaySources] =
await Promise.all([
readInventory(),
readArtifacts(),
Promise.all(LOCALES.map((locale) => readStrings(locale))),
readStrings("values", WEAR_RESOURCE_ROOT),
readAndroidSource(),
readToolDisplaySources(),
]);
const [
inventory,
artifacts,
localeStrings,
thirdPartyBaseStrings,
wearBaseStrings,
sourceFiles,
toolDisplaySources,
] = await Promise.all([
readInventory(),
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(
@@ -1201,6 +1228,11 @@ export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
.filter((entry) => entry.path === WEAR_STRINGS_REPO_PATH)
.map((entry) => [entry.source, entry]),
);
const thirdPartyInventoryBySource = new Map(
inventory
.filter((entry) => entry.path === THIRD_PARTY_STRINGS_REPO_PATH)
.map((entry) => [entry.source, entry]),
);
const manualBase = [...baseStrings.values()].filter(
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
);
@@ -1276,11 +1308,26 @@ export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
wearBaseStrings,
wearInventoryBySource,
artifactEntriesById(artifacts.get(locale) ?? []),
"Wear",
);
resources.set(
path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
renderStringsXml(wearManual, new Map()),
);
const thirdPartyManual = localizeManualStrings(
thirdPartyBaseStrings,
thirdPartyInventoryBySource,
artifactEntriesById(artifacts.get(locale) ?? []),
"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) {
@@ -1378,6 +1425,7 @@ export async function syncAndroidAppI18n(
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;
@@ -1423,17 +1471,29 @@ export async function syncAndroidAppI18n(
}
export async function verifyAndroidAppI18n() {
const [sourceFiles, base, referenceSource, wearBase, wearReferenceSource] = await Promise.all([
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));
@@ -1441,6 +1501,10 @@ export async function verifyAndroidAppI18n() {
"App English unused",
findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource),
]);
problems.push([
"Third-party English unused",
findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource),
]);
problems.push([
"Wear English unused",
findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource),
@@ -1456,7 +1520,7 @@ export async function verifyAndroidAppI18n() {
throw new Error(formatProblems(problems));
}
process.stdout.write(
`android-app-i18n: appSourceKeys=${baseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
`android-app-i18n: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
);
}
@@ -1467,6 +1531,13 @@ export async function checkAndroidAppI18n(options: { tolerateManagedPending?: bo
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,
@@ -1499,6 +1570,15 @@ export async function checkAndroidAppI18n(options: { tolerateManagedPending?: bo
});
};
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)));
+35
View File
@@ -59,6 +59,8 @@ const TRANSLATIONS_DIR = path.join(ROOT, "apps", ".i18n", "native");
const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
android: [
path.join(ROOT, "apps", "android", "app", "src", "main"),
path.join(ROOT, "apps", "android", "app", "src", "play"),
path.join(ROOT, "apps", "android", "app", "src", "thirdParty"),
path.join(ROOT, "apps", "android", "wear", "src", "main", "res", "values"),
],
apple: [
@@ -173,6 +175,38 @@ const GENERATED_PATH_RE = /(?:^|[\\/])(?:build|\.gradle|\.build|DerivedData)(?:$
const EXCLUDED_PATH_RE = /(?:^|[\\/])(?:Tests?|UITests?|test|Preview(?:s)?)(?:$|[\\/])/u;
const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u;
const GENERATED_FILE_RE = /(?:^|[\\/])NativeStringResources\.kt$/u;
// These files emit mobile.ui protocol evidence, not UI copy. Keep their text verbatim so
// developer-screen diagnostics match the agent-facing action results and snapshot refs.
const ANDROID_NATIVE_I18N_EXCLUDED_FILES = new Set([
path.join(
ROOT,
"apps",
"android",
"app",
"src",
"thirdParty",
"java",
"ai",
"openclaw",
"app",
"accessibility",
"AccessibilityActionExecutor.kt",
),
path.join(
ROOT,
"apps",
"android",
"app",
"src",
"thirdParty",
"java",
"ai",
"openclaw",
"app",
"accessibility",
"AccessibilitySnapshotter.kt",
),
]);
const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu;
const NATIVE_I18N_LOCALE_SET = new Set<string>(NATIVE_I18N_LOCALES);
const ANDROID_LANGUAGE_PICKER_PATH =
@@ -1115,6 +1149,7 @@ async function walkFiles(root: string, surface: NativeI18nSurface): Promise<stri
const allowed = surface === "apple" ? APPLE_EXTENSIONS : ANDROID_EXTENSIONS;
return entry.isFile() &&
(allowed.has(extension) || isAndroidValuesXml) &&
!ANDROID_NATIVE_I18N_EXCLUDED_FILES.has(fullPath) &&
!EXCLUDED_FILE_RE.test(entry.name) &&
!GENERATED_FILE_RE.test(fullPath)
? [fullPath]
+24
View File
@@ -83,6 +83,21 @@ describe("Android app i18n resources", () => {
}
});
it("builds complete third-party flavor resources for every native locale", async () => {
const catalog = await buildAndroidAppI18nCatalog();
const resources = [...catalog.resources].filter(
([filePath]) =>
filePath.includes("/apps/android/app/src/thirdParty/res/values-") &&
filePath.endsWith("/accessibility_strings.xml"),
);
expect(resources).toHaveLength(NATIVE_I18N_LOCALES.length);
for (const [, content] of resources) {
expect(content).toContain('name="accessibility_service_label"');
expect(content).toContain('name="accessibility_dev_activity_label"');
}
});
it("preserves the existing Swedish app name", async () => {
const strings = await readFile("apps/android/app/src/main/res/values-sv/strings.xml", "utf8");
expect(strings).toContain('<string name="app_name">OpenClaw-nod</string>');
@@ -460,4 +475,13 @@ describe("Android app i18n resources", () => {
),
).toEqual([]);
});
it("scans flavor-specific activity surfaces", () => {
expect(
findUnlocalizedAndroidUiLiterals(
'Text("Developer surface")',
"apps/android/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt",
).map((finding) => finding.source),
).toEqual(["Developer surface"]);
});
});
+18
View File
@@ -349,6 +349,8 @@ describe("native app i18n inventory", () => {
.every(
(entry) =>
entry.path.startsWith("apps/android/app/src/main/") ||
entry.path.startsWith("apps/android/app/src/play/") ||
entry.path.startsWith("apps/android/app/src/thirdParty/") ||
entry.path === "apps/android/wear/src/main/res/values/strings.xml",
),
).toBe(true);
@@ -359,6 +361,22 @@ describe("native app i18n inventory", () => {
entry.source === "Current session",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path.endsWith(
"/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt",
) && entry.source === "Control other apps",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.path.endsWith("/accessibility/AccessibilityDevActivity.kt") &&
entry.source === "Accessibility executor",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "n${nodes.size}")).toBe(false);
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
expect(
entries.some((entry) =>