mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(i18n): cover Android flavor surfaces
This commit is contained in:
+94
-14
@@ -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 = /(?:'|(?<!\\)')/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)));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user