Automation Pipeline6 min read

Lint said nine strings were missing translations. The real number was 509

MissingTranslation only checks whether the key exists in the locale file. A value that is still the English original passes. Users in five languages were reading half the app in English, and every metric said fully translated.

#localization#verification#android#automation#gotchas
Concept diagram: three layers — key exists, value translated, string present in the catalog — and what each check sees
A concept diagram summarizing the post.

A release check on one Android app produced 9 MissingTranslation findings. I started out thinking I'd fill those nine and be done.

A full comparison found 509.

One question first. Does your translation check ask "does the key exist" or "is the value translated"? Those are different questions.

1. Lint only sees whether the key exists

MissingTranslation checks only that the key is present in the locale file. A value that is still the English original passes.

That app's German, Spanish, French, Italian and Brazilian Portuguese had been added later, as straight copies of English.

it 104 · pt-rBR 102 · de 101 · es 101 · fr 101   = 509

Included: the entire paywall (22 keys), the scan screen, barcode lookup, notifications, recipes, the account screen. Users in those five countries were reading half the app in English — and every metric said fully translated.

The store that spoke a language the app did not and the subtitle with the same hole are other layers of this same audit. Put it next to the contrast check that passed while the design died and the shape is identical: the checker passed something that is broken for the user.

2. This is a layered problem

A check that watches one layer always passes on the others. The same audit caught me on two more.

Layer ① — the source string was never in the catalog

The translation validator reports "every key × every locale translated" while the app ships in English, because the validator only looks inside the catalog.

If a string that exists in source is absent from the catalog, the check has 0 items — and 0 items is always 100% passing.

There is a worse variant. If the key exists with no value, the system renders the key string itself. The user reads session.error.outOfMinutes. That is worse than English.

Layer ② — units glued after interpolation, Latin-only values in non-Latin locales

Two leaks where the locale files are complete and the key counts match, and English still reaches the screen.

Units the code appends, like "${streak}d", bypass resources entirely. A Russian stats screen rendered "7d". And the Russian difficulty labels read Новичок / Casual / Профи / Босс — "Boss" was transliterated, "Casual" was not.

3. But this detection mixes in perfectly correct cases

This is the important part. Count them and report the number and you will be wrong.

Running it across the fleet gave 247 findings in 15 apps. I checked a sample one by one, and most were correct as they stood.

  • Proper nouns, institution names, product names
  • Medical terms that are identical across several languages
  • Loanwords in that language: Italian "Password" and "Account", French "Date", Spanish "Legal"
  • Debug and placeholder strings

⚠️ I first claimed "some of these are real" and named keys in two apps. All of them were wrong. Checking the evidence key by key, not one was a defect.

The evidence always lived in other keys in the same locale.

  • One locale in one app had translated the paired keys and left exactly two in English — a deliberate loanword choice for that register. In another file of the same locale you can see the translator adjusting English singular/plural forms by hand.
  • "Digestion" and "Fatigue" are French words.
  • One locale uses that English word as a loanword throughout its copy; an English tab label is the consistent choice there.

Conclusion: zero code changes in those two apps. Fix by the number and you translate proper nouns and invert loanwords.

The order of magnitude is usable as a signal. 509 vs ≤18 per app everywhere else. Different magnitude, different nature: the first is a pipeline accident, the second is vocabulary judgment.

4. How would you fill 509 strings?

Five languages × ~100 keys.

  • (a) Bulk-fill with a machine translation API
  • (b) Translate by hand
  • (c) Harvest translations that are already validated somewhere else

I took (c). Terminology comes from that app's own existing translations.

⚠️ Copy from another app and terminology splits inside the app. Two apps render Chinese "account" differently. If no app has the same key, look for a key whose English original is identical.

And duplicate keys should be deleted, not translated. There were two keys meaning "cancel", and only the less-used one had 15 locales of translation. I changed the call sites and deleted the key — 15 translations saved.

5. Detection differs per layer

Layer ② (value not translated): does the locale value exactly equal the base locale value?

same value + length >= 6 + contains Latin characters + not a brand/proper noun

Layer ① (absent from the catalog): put a source → catalog comparison in the validator. Extract the literals and lookup keys from calls that produce on-screen copy (Text|Label|Button|Link|Section|navigationTitle|accessibilityLabel, …), convert interpolation into the format specifiers the catalog uses (\(n) minutes%lld minutes), and subtract the catalog's key set. Exclude capture-only screens. The reverse direction (keys only in the catalog) is just dead keys, so it doesn't block.

Interpolation leaks: find characters glued after an interpolation.

grep -rn '}\w*"' --include='*.kt'

The fix is usually dropping the unit. If the card title already says "streak", the unit is noise — and 16 translations and a plural rule disappear with it. Latin-only values in non-Latin locales are caught by ^[A-Za-z0-9 .,!?%-]+$, with brands and genuine idioms on an exclusion list.

6. Verify by looking the string up in a real locale

In an instrumentation test, switch the locale, look the string up, and assert the value differs from English. That sees the layer lint cannot.

Negative control: revert one key to English and the test must fail naming that key. Placeholders get a multiset comparison before and after substitution.

Result: 498 strings filled and shipped.

Three things to check in your own pipeline

  1. Does your translation checker print how many items it checked? Zero items is 100% passing. A checker that hides its denominator has no business reporting a pass rate.
  2. How many keys have a locale value character-for-character identical to English? One line of script counts it.
  3. Does your code append units or suffixes to strings? Those characters bypass your resources.

The honest part

My biggest error in this audit wasn't the 509 — it was the 247. I saw a number and concluded "the whole fleet looks like this", then opened them one by one and found nearly all of them were translators making the right call. My checker simply doesn't know that "Password" is also "Password" in Italian.

If I had trusted the checker and fixed by the list, I would have inverted the loanwords of 15 apps. This post's claim — that checkers have layers they cannot see — applies to my checker too.

Do one thing today. Open one locale file and count the lines whose value is identical to the base locale. If that count runs to three digits, it isn't vocabulary judgment; it's a pipeline accident.

Related