◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-10-22Intermediate

Translations Drifted Apart Without Me Noticing: A 60-Line strings.xml Check to Run Before Asking an Agent to Review

A small Python script that finds missing keys, empty strings, and placeholder mismatches across Android locales, plus how to hand only its findings to Antigravity for fix proposals.

Android28strings.xmllocalization7Antigravity394Python22i18n5

The night before an update to my indie wallpaper app, I opened the German build and triggered the "saved" message. Where a number should have been, there was nothing.

The cause was a single line in strings.xml. The base language had both %1$s and %2$d; the German version only had %1$s. It didn't crash. The screen just read slightly wrong — which is exactly why it had slipped past review.

So I asked the agent to "check every locale's strings.xml." What came back was a confident-sounding summary. When I counted again mechanically later, several gaps it hadn't mentioned were still there.

Let a machine count; let the agent think about the fix. Here is how I arrived at that line.

"Look at everything" gets skimmed

Locale files are long runs of near-identical lines. Just as a human reviewer would, a model tends to fold similar rows into "looks fine."

There is a second reason. A missing key leaves nothing on the page to notice. It isn't a problem you spot by reading a line; it only shows up when you compare against the base. And comparing is just a set difference — work I'm happy to give to a script.

Four things to check mechanically

I narrowed the checks to four.

CheckWhat it findsSymptom if ignored
missingKeys in the base file but not in a localeBase-language text leaks into the UI
orphanKeys that exist only in a localeInvisible in the UI, but clutters later cleanups
emptyBlank translationsButtons or labels render empty
placeholder_mismatchDifferent count or type of %1$s-style tokensNumbers vanish, or a type mismatch fails at runtime

Keys marked translatable="false" are dropped first, since they are not meant to be translated — an app name used verbatim in every language, for instance.

The script

It runs on the standard library alone. Pass the res directory; it prints per-locale problems as JSON and exits with 1 if anything is found.

#!/usr/bin/env python3
"""Detect per-locale drift across res/values*/strings.xml.
Usage: python3 check_strings.py app/src/main/res
Exit code: 0 = clean / 1 = problems found
"""
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
 
PLACEHOLDER = re.compile(r"%(?:\d+\$)?[-#+ 0,(]*\d*(?:\.\d+)?[sdfxXeEgGc]|%%|\{\w+\}")
 
 
def load(path):
    """name -> text. Skips translatable="false"."""
    result = {}
    for node in ET.parse(path).getroot().findall("string"):
        if node.get("translatable") == "false":
            continue
        result[node.get("name")] = "".join(node.itertext())
    return result
 
 
def placeholders(text):
    return sorted(PLACEHOLDER.findall(text))
 
 
def main(res_dir):
    res = Path(res_dir)
    base = load(res / "values" / "strings.xml")
    report = {}
    for path in sorted(res.glob("values-*/strings.xml")):
        locale = path.parent.name.removeprefix("values-")
        target = load(path)
        issues = {
            "missing": sorted(set(base) - set(target)),
            "orphan": sorted(set(target) - set(base)),
            "empty": sorted(k for k, v in target.items() if k in base and not v.strip()),
            "placeholder_mismatch": sorted(
                k for k, v in target.items()
                if k in base and placeholders(v) != placeholders(base[k])
            ),
        }
        issues = {k: v for k, v in issues.items() if v}
        if issues:
            report[locale] = issues
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 1 if report else 0
 
 
if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("usage: check_strings.py <res_dir>")
    sys.exit(main(sys.argv[1]))

I built a tiny sample and planted four different problems in the German file. The output was:

{
  "de": {
    "missing": ["favorite_added"],
    "orphan": ["old_key"],
    "empty": ["retry"],
    "placeholder_mismatch": ["download_done"]
  }
}

All four planted problems came back, exactly as planted. That finishes the counting half of the review.

Hand Antigravity the findings, not the files

Give the agent the script's output rather than the full strings.xml files. A prompt along these lines works:

Here is the output of check_strings.py.
Propose fixes per locale, only for the keys listed in this JSON.
- The base is values/strings.xml
- For placeholder_mismatch, keep the same placeholders as the base
- Do not touch keys that are not listed

The last line matters most. Restricting the scope to listed keys stops the agent from rephrasing neighboring strings "while it's there," and the diff stays easy to review.

After the fixes land, run the script again. One round is done when the exit code is 0.

What still needs a human

An "empty" report isn't always wrong. In some language you may deliberately show nothing.

Reordered placeholders can also be legitimate. If you write %1$s and %2$d with positions, a different word order still works. The regex here compares sorted tokens, so order alone is not flagged. Two bare %s tokens are a different story: swap the word order and the meaning swaps too, so I check those by eye.

One more gap: plurals aren't covered. The number of required forms differs per language, and a plain comparison can't judge that. It needs its own check.

Making it a habit

A wallpaper app picks up a few new strings with every update, so the gaps between languages only widen if I leave them alone. I put the script right before cutting a release branch. Because it uses an exit code, it also fits a pre-commit hook.

Give the agent only the facts a machine has found. With facts up front, the prompt gets shorter and the returned fixes stay small.

Run it once on your own project today. If it prints an empty {}, that becomes your baseline. If it prints something, that line is the quickest thing you can fix today.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
▣ App Dev2026-05-25
One Month Splitting Antigravity's Inline Edit and Agent Mode Across Four Wallpaper Apps
A month of notes from running Antigravity's Inline Edit and Agent Mode across four production wallpaper apps — with real counts, the decision rule I wrote into AGENTS.md, a one-pass dSYM fix, and how credit cost factors in.
▣ App Dev2026-10-20
My PHP API Returned no-store and Cloudflare Passed Everything Through: Overriding Only the Edge TTL
The list API behind my wallpaper apps returned no-store, so putting Cloudflare in front of it changed nothing: every request still reached the origin. This note covers a Cache Rule that overrides only the edge TTL, a small script that proves it works, and a deploy step that pairs upload and purge so stale responses don't linger.