fix(i18n): fail CI when a translated plurals has no "other" form

Codeberg #297 and #298 are the same crash, reported twice from fr-FR:

    Resources$NotFoundException: Plural resource ID #0x7f0f0000
                                 quantity=14 item=other

values-fr translated agenda_range_days with only "one" and "many".
French "many" matches millions only, so every count from 2 upwards
resolves to "other" — which the translation doesn't define. Android
falls back to "other" for any quantity form it can't find, but nothing
falls back for "other" itself and there is no fall back to the base
locale either, so getQuantityString throws. Any French user with a
custom agenda range of 2+ days crashed the moment that label composed:
the settings summary, the range picker, and the live preview while
typing a day count.

Nothing caught it. The lint config downgrades MissingQuantity on the
grounds that a missing form "falls back to other at runtime" — true of
every form except "other", which is the one that was missing here. And
check_translations.py treated <plurals> as an opaque key via
RESOURCE_TAGS, never looking inside at quantities.

So enforce the one invariant Android actually requires: a translated
<plurals> must carry an "other" item. Locales may still skip forms
their language rarely uses (Arabic "zero", the missing Italian "many"
in search_delete_title) because those do fall back. Lint's
MissingQuantity can't tell the two cases apart, which is why this lives
in the script rather than in a lint severity, and the stale lint comment
is corrected to say so.

The broken French string itself is owned by Weblate and is fixed there.
This commit is contained in:
2026-09-12 16:10:07 +02:00
parent 20c20b71ea
commit 8cd9a7b4e1
2 changed files with 56 additions and 13 deletions
+10 -6
View File
@@ -113,12 +113,16 @@ android {
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
// it. A translated <plurals> may likewise skip a CLDR quantity form its
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
// any form it cannot find, so MissingQuantity is informational too.
// What a translation must NOT skip is "other" itself — nothing falls back
// for that one, not even the base locale, so it throws
// Resources$NotFoundException at runtime (Codeberg #297/#298).
// MissingQuantity doesn't tell the two cases apart, so that invariant is
// enforced by scripts/check_translations.py instead, with a clearer
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
// fatal.
informational += listOf("MissingTranslation", "MissingQuantity")
}