diff --git a/scripts/android-app-i18n.ts b/scripts/android-app-i18n.ts index 4f24a7b720ce..0521e89014f6 100644 --- a/scripts/android-app-i18n.ts +++ b/scripts/android-app-i18n.ts @@ -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 = /(?:'|(?"; 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 { async function readStrings( locale: string, resourceRoot = RESOURCE_ROOT, + fileName = "strings.xml", ): Promise> { - 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, inventoryBySource: ReadonlyMap, artifactEntries: ReadonlyMap, + 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 { } export async function buildAndroidAppI18nCatalog(): Promise { - 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 { .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 { 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(); 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 = [ ["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))); diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index df9889193d92..73502fcc2139 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -59,6 +59,8 @@ const TRANSLATIONS_DIR = path.join(ROOT, "apps", ".i18n", "native"); const SOURCE_ROOTS: Record = { 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(NATIVE_I18N_LOCALES); const ANDROID_LANGUAGE_PICKER_PATH = @@ -1115,6 +1149,7 @@ async function walkFiles(root: string, surface: NativeI18nSurface): Promise { } }); + 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('OpenClaw-nod'); @@ -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"]); + }); }); diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index 71037f5a264d..950664ce3f54 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -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) =>