fix: French agenda-range plural crash (#297, #298) (#301)

This commit is contained in:
Jean-Luc Makiola
2026-09-12 16:27:30 +02:00
parent 20c20b71ea
commit 08f7228a69
5 changed files with 67 additions and 16 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **A custom agenda range crashed the app in French.** Choosing a custom range
for the agenda — or simply typing a number into that field — closed the app on
any phone set to French. The French translation of the "%d days" label was
missing the wording French uses for counts of two and above, and Android has
nothing to fall back on when that particular form is absent, so every value
except one day was affected ([#297], [#298]).
## [2.20.2] — 2026-09-12
### Fixed
+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")
}
-3
View File
@@ -655,12 +655,9 @@
<string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string>
<string name="crash_dialog_report">Справаздача</string>
<string name="crash_dialog_dismiss">Ня зараз</string>
<string name="crash_report_issue_title">Справаздача аб краху</string>
<string name="crash_report_clip_label">Справаздача аб краху %1$s</string>
<string name="crash_report_copied">Справаздача скапіявана ў буфэр абмену</string>
<string name="crash_report_open_failed">Не атрымалася адкрыць сыстэму адсочваньня праблемаў. Справаздача знаходзіцца ў буфэры абмену.</string>
<string name="crash_report_body_template">Дзякуй за справаздачу аб краху ў %1$s. Дадайце ўсё, што памятаеце пра тое, што рабілі, а затым адпраўце.\n\n### Што здарылася\n\n\n### Справаздача аб краху\n%2$s\n</string>
<string name="crash_report_body_paste">_(Справаздача задоўгая для гэтай спасылкі — устаўце яе з буфэра абмену сюды.)_</string>
<string name="special_dates_calendar_birthday">Дні нараджэньня</string>
<string name="special_dates_calendar_anniversary">Юбілейныя даты</string>
<string name="special_dates_calendar_custom">Асаблівыя даты</string>
+1
View File
@@ -323,6 +323,7 @@
<plurals name="agenda_range_days">
<item quantity="one">%d jour</item>
<item quantity="many">%d jours</item>
<item quantity="other">%d jours</item>
</plurals>
<string name="settings_section_views">Vues</string>
<string name="settings_quick_switch_header">Bouton dinterrupteur rapide</string>
+46 -7
View File
@@ -8,7 +8,10 @@ and are produced via Weblate. This guard keeps incoming translation PRs honest:
* a translation must not define keys absent from the base — those are stale
keys left behind after a rename/removal upstream;
* a translation must not translate strings marked ``translatable="false"`` in
the base (URLs, IDs and the like).
the base (URLs, IDs and the like);
* every translated ``<plurals>`` must carry an ``other`` item, because that
is the one quantity form Android cannot fall back for (see
``plurals_missing_other``).
Missing keys are *allowed* and only reported as coverage: a missing string
falls back to the English base at runtime, so partial translations are fine
@@ -27,10 +30,13 @@ RES_DIR = Path("app/src/main/res")
BASE = RES_DIR / "values" / "strings.xml"
RESOURCE_TAGS = ("string", "plurals", "string-array")
# The quantity Android itself falls back to, and therefore the one a translated
# <plurals> may never omit.
PLURAL_FALLBACK = "other"
def entries(path: Path) -> dict[str, bool]:
"""Map resource name -> is-translatable for every entry in ``path``."""
root = ET.parse(path).getroot()
def entries(root: ET.Element) -> dict[str, bool]:
"""Map resource name -> is-translatable for every entry under ``root``."""
return {
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
for el in root
@@ -38,12 +44,35 @@ def entries(path: Path) -> dict[str, bool]:
}
def plurals_missing_other(root: ET.Element) -> list[str]:
"""Names of ``<plurals>`` under ``root`` that lack an ``other`` item.
Android resolves the CLDR quantity for a count and, if that specific form is
absent, falls back to ``other`` — so a translation may legitimately skip
forms its locale rarely uses (Arabic ``zero``, French ``many``). There is no
fallback for ``other`` itself, and none to the base locale either: a
translated ``<plurals>`` without it raises
``Resources$NotFoundException`` as soon as a count selects the missing form.
That is Codeberg #297/#298 — ``values-fr`` translated ``agenda_range_days``
with only ``one`` and ``many``, and since French ``many`` matches only
millions, every custom agenda range from 2 days up crashed the app.
"""
return sorted(
el.attrib["name"]
for el in root
if el.tag == "plurals"
and "name" in el.attrib
and not any(item.attrib.get("quantity") == PLURAL_FALLBACK for item in el.findall("item"))
)
def main() -> int:
if not BASE.exists():
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
return 1
base = entries(BASE)
base = entries(ET.parse(BASE).getroot())
base_keys = set(base)
nontranslatable = {name for name, ok in base.items() if not ok}
translatable_total = len(base_keys - nontranslatable)
@@ -57,16 +86,18 @@ def main() -> int:
for path in files:
locale = path.parent.name[len("values-"):]
try:
translated = entries(path)
root = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"::error file={path}::{locale}: malformed XML: {exc}")
errors += 1
continue
translated = entries(root)
keys = set(translated)
stale = sorted(keys - base_keys)
translated_fixed = sorted(keys & nontranslatable)
missing = base_keys - nontranslatable - keys
no_other = plurals_missing_other(root)
for name in stale:
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
@@ -77,10 +108,18 @@ def main() -> int:
"in the base and must not be translated"
)
errors += 1
for name in no_other:
print(
f"::error file={path}::{locale}: plurals '{name}' has no "
f"<item quantity=\"{PLURAL_FALLBACK}\"> — Android cannot fall back for it "
"and will crash when a count selects it; translate that form or drop the "
"whole plurals to inherit the base"
)
errors += 1
covered = translatable_total - len(missing)
pct = covered * 100 // translatable_total if translatable_total else 100
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
verdict = "OK" if not (stale or translated_fixed or no_other) else "FAIL"
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
if errors: