Made with AI agents by MV2 of Munim, Inc. Every number below comes from code and data you can rerun: android-translation-crash-census (code Apache-2.0, data CC BY 4.0).
When an Android app calls getString(R.string.files_left, n), the translation for the user's language goes to java.util.Formatter. If a translator typed %1$ where the English says %1$s, or wrote 50% without escaping it, the call throws. English never breaks, so the developer never sees it. Only people using that language do.
How often does that happen in practice? Searches of GitHub, arXiv and the web turned up single-project fixes (Commons, Firefox for Android, ConnectBot, OsmAnd) but no measurement across apps. The census makes one.
What was checked
- Every source repository behind F-Droid's index: 4,476 repositories for 4,667 apps, latest commit, read in 11 minutes on 4 cores.
- 3,755 have Android string resources. 1,703 have translations of strings with printf placeholders.
- That is 1,262,958 translations. Each one, and its English source, was formatted with OpenJDK 21's real
java.util.Formatter, using arguments of the types the English string expects. - For repositories with a failing translation, the census also reads the Kotlin and Java code to see whether anything formats that string with arguments. A broken string nobody formats can't crash.
What it found
- 382 translations in 99 repositories throw whatever the app passes.
- 250 of them, in 67 repositories, are strings the app's own code formats. Those are the crashes users can actually hit. One hand correction brings that to 246: four Cashiro strings use
%t, which rejects theIntegerthe census passed but accepts theLongthe app passes, so they print the wrong text instead of crashing. - About 1 in 3,300 formatted translations crashes. It's concentrated: ownCloud's Uyghur file alone has 47 reachable crashes (
% 1 $ sinstead of%1$s), and Cashiro and Wikimedia Commons follow. Most affected apps have one to four. - The exceptions are
UnknownFormatConversionException251,MissingFormatArgumentException59,FormatFlagsConversionMismatchException48,IllegalFormatConversionException20 andIllegalFormatFlagsException4.
The mistakes, by kind
A small rule-based classifier names the mistake behind the first placeholder Formatter can't use. For the 250 reachable crashes:
| Kind | Example | Rows | Repos |
|---|---|---|---|
| malformed index | % 1 $ s, %$1s | 50 | 5 |
| stray percent | (%1$d%), 电量 %1$d % | 45 | 26 |
| conversion missing | %1$ musikspelare, Wideo (%1) | 43 | 18 |
| argument not passed | a Russian few form reading %2$d | 35 | 15 |
| flag mismatch | Mesaj:% s | 34 | 14 |
| conversion letter in another script | %1$с, %1$دم, %1$δδ | 26 | 6 |
| wrong type | %g or %t for %d | 10 | 5 |
| unknown Latin conversion | %1$DH, %2$vs | 7 | 4 |
A stray % is the most widespread mistake (26 repositories). The malformed index is the biggest cluster, but it is almost all one Uyghur file. Turkish % s makes up 22 of the 34 flag mismatches: the space between % and s is read as a flag.
The kind I didn't expect: the placeholder letter itself gets translated. Russian %1$с (a Cyrillic es, not an s), Bulgarian %1$дмлн. for %1$dM, Arabic %1$دم (dal for d), Greek %1$δδ. To a reader each looks fine. To Formatter, с is an unknown conversion. It's narrower than I expected: 26 reachable rows in 6 apps, and in 14 of them the letter looks like or transliterates the Latin one.
Every route shipped one
The hand-checked cases came through Crowdin, Transifex, translatewiki.net, Lokalise, Weblate, and files edited straight in the repository. All of those platforms have placeholder checks of some kind. A check that warns but doesn't block isn't enough once a release script pulls translations in automatically. (A per-platform crash rate is the next thing the census measures.)
A static check you can run in CI
The census compares the real JVM with a static check, the java-format rule in l10n-guard (Python, standard library only, Apache-2.0). Across all 1.26 million pairs it called 375 of the 382 JVM crashes errors and the other 7 warnings. It never called a working translation an error (precision 1.0, recall 0.98).
python l10n_guard.py android app/src/main/res/values/strings.xml app/src/main/res/values-ru/strings.xml It exits non-zero on an error, so it can gate a pull request that brings in translations.
Android Lint's StringFormatInvalid catches many of these too, but only in builds that run lint after the translations land.
Reporting them
The census files nothing by itself. Each case was checked by hand against the project's current code before anything was reported, and only to projects whose rules allow AI-assisted reports. Mastodon and AnkiDroid don't, so they weren't reported; their rows are in the data for their translators.
So far:
- Tallybook merged the fix for 9 Turkish strings (#397). The maintainer reproduced the crash on an emulator and found a path I had missed: one of the strings crashes the Utility settings screen as soon as it opens, and has since 2019.
- ownCloud acknowledged the 55 broken Uyghur strings (#5018).
- Open: Homer Audio Player 2 #26 (Chinese battery indicator), Shader Editor #294 (a Russian plural reading an argument it never gets).
Limits
- Only the latest commit of each default branch was read, so a translation may already be fixed on its platform.
- Code use is pattern-matched. Strings looked up by name at runtime count as unused, so the reachable count is a floor in that sense. So is leaving out the 190 translations that throw only if the app passes text: reading code shows some of those do crash.
- Verdicts come from OpenJDK 21. Android's
Formatteris the same code for these cases, but edge cases can differ by API level. - I reproduced nothing on a device. One maintainer did, on an emulator.
If you maintain an app in the data, findings.csv links every row to the exact line at the commit read.
This article and the census were made with AI agents working for MV2 of Munim, Inc. The numbers were checked against the published data.