diff --git a/scripts/android-app-i18n.ts b/scripts/android-app-i18n.ts index d1838a3d8a64..4ad26064f541 100644 --- a/scripts/android-app-i18n.ts +++ b/scripts/android-app-i18n.ts @@ -272,6 +272,9 @@ const UI_BRANCH_LITERAL_RE = /(?:->|return|\?:)\s*"((?:\\.|[^"\\])+)"/gu; const UI_IF_BRANCH_LITERAL_RE = /(?:\bif\s*\([^)]*\)|\belse)\s*\{\s*"((?:\\.|[^"\\])+)"/gu; const UI_MODEL_STRING_NAME_RE = /^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u; +const UI_MODEL_CLASS_FIELDS = new Map>([ + ["SettingsToggleRow", new Set(["subtitle", "title"])], +]); const KOTLIN_STRING_LITERAL_RE = /"((?:\\.|[^"\\])+)"/gu; // These literals are either technical data or immutable source tokens localized at a typed render edge. @@ -592,11 +595,14 @@ function collectTypedModelLiteralFindings( 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)) userFacingParameters.set(field, index); + 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}\\s*\\(`, "gu"); @@ -729,7 +735,11 @@ function renderStringsXml( manual: readonly ResourceString[], generated: ReadonlyMap, ): string { - const lines = [""]; + const lines = [ + generated.size > 0 + ? '' + : "", + ]; for (const entry of manual) { lines.push(` ${entry.rawValue}`); } @@ -740,8 +750,10 @@ function renderStringsXml( )) { const formatted = INTERPOLATION_RE.test(entry.source) ? "" : ' formatted="false"'; INTERPOLATION_RE.lastIndex = 0; + // 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)}"`, + ` "${renderAndroidResourceValue(entry.source, entry.value)}"`, ); } } diff --git a/test/scripts/android-app-i18n.test.ts b/test/scripts/android-app-i18n.test.ts index 694abcc0b762..9bd49892353f 100644 --- a/test/scripts/android-app-i18n.test.ts +++ b/test/scripts/android-app-i18n.test.ts @@ -10,6 +10,11 @@ import { describe("Android app i18n resources", () => { it("keeps generated resources, runtime coverage, and every locale aligned", async () => { await expect(checkAndroidAppI18n()).resolves.toBeUndefined(); + const base = await readFile("apps/android/app/src/main/res/values/strings.xml", "utf8"); + expect(base).toContain('xmlns:tools="http://schemas.android.com/tools"'); + expect(base).toMatch( + /]*tools:ignore="Typos,TypographyDashes,TypographyEllipsis">/u, + ); }); it("preserves the existing Swedish app name", async () => { @@ -43,6 +48,10 @@ describe("Android app i18n resources", () => { val connected: Boolean, val statusText: String, ) + data class SettingsToggleRow( + val title: String, + val subtitle: String, + ) Text("Settings") Text(text = nativeStringResource("Connected")) @@ -51,6 +60,8 @@ describe("Android app i18n resources", () => { SettingsMetric("Gateway", gatewayName) ConnectionState(connected = false, statusText = "Connecting to $host") ConnectionState(connected = true, statusText = nativeString("Connected")) + SettingsToggleRow("Phone capability", "Share device data") + SettingsToggleRow(nativeString("Localized capability"), nativeString("Localized detail")) Text(text = fileName ?: "Attachment") Modifier.clickable(onClickLabel = "Open detail", onClick = {}) Text(nativeString("First sentence. ") + "Second sentence.") @@ -73,6 +84,8 @@ describe("Android app i18n resources", () => { expect.objectContaining({ source: "Working" }), expect.objectContaining({ source: "Gateway" }), expect.objectContaining({ source: "Connecting to $host" }), + expect.objectContaining({ source: "Phone capability" }), + expect.objectContaining({ source: "Share device data" }), expect.objectContaining({ source: "Attachment" }), expect.objectContaining({ source: "Open detail" }), expect.objectContaining({ source: "Second sentence." }),