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
- 371 translations in 98 repositories throw with the arguments the English string expects.
- 238 of them, in 66 repositories, are strings the app's own code formats. Those are the crashes users can actually hit. That count is after two corrections made by reading rows by hand (below).
- About 1 in 3,400 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,MissingFormatArgumentException48,FormatFlagsConversionMismatchException48,IllegalFormatConversionException20 andIllegalFormatFlagsException4.
Two false positives the census found in itself
The first run reported 382 crashes, 250 of them reachable. Reading the rows against each app's code took 12 out of the reachable 250, and 11 out of the 382:
-
%tandLong. Four Cashiro strings use%twhere the English has%d.%trejects theIntegerthe census passed but accepts theLongthe app passes, so they print the wrong text instead of crashing. -
Markup that is text. Wikipedia writes some English strings in CDATA:
<![CDATA[... attributed to %1$s. <a href="%2$s">Learn more</a>.]]>. To Android that link is plain text, so the app formats it with two arguments and passes it toHtml.fromHtml. The checker removed every<...>before reading placeholders, so the%2$sin thehrefdisappeared from the English. Translations whose link markup was itself broken kept their%2$s, and looked like they read an argument the app never passes. They show a broken link, but they don't throw. That is six rows in Danish, Indonesian, Portuguese, Romanian and Russian. Five more were invalues-qq, translatewiki.net's notes for translators, which no device loads. All 11 are gone from the totals above, and 8 of them were in strings the code formats.
The real JVM agreed with the static check on all of these, because both read the same extracted text. Checking the checker against a JVM doesn't catch a mistake in what you feed them both. Census 0.3.1 fixes the extraction (only real XML elements lose their markup), and a full re-run will list every row that changes.
The mistakes, by kind
A small rule-based classifier names the mistake behind the first placeholder Formatter can't use. For the 242 reachable rows left after the Wikipedia correction (238 after %t):
| 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 |
| flag mismatch | Mesaj:% s |
34 | 14 |
| argument not passed | a Russian few form reading %2$d
|
27 | 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). Of the 371 JVM crashes it called 364 errors and the other 7 warnings, and it called nothing an error that the JVM accepted. Both share the text-extraction mistake above, which the next l10n-guard release fixes.
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), FadCam #360 (Italian and Greek strings in video deletion and export).
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.
- It can also overcount, as the two corrections above show. Both were found by reading rows by hand, and more may exist.
- 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.
Top comments (0)