Compare commits

...
Author SHA1 Message Date
makiolaj f64cb24c6b docs(changelog): note the French agenda-range crash fix 2026-09-12 16:17:09 +02:00
makiolaj 74e39c1c7b chore(i18n): drop Belarusian keys removed from the base in 2.20.2
Release v2.20.2 removed crash_report_issue_title,
crash_report_body_template and crash_report_body_paste from the base
strings.xml, but values-be still carried its translations of them.
check_translations.py fails on stale keys, and since translations.yaml
runs on every PR with no path filter, the required "Translations / check"
status has been red for every pull request opened since that release.

There is nothing left for these to translate, so remove them.
2026-09-12 16:16:14 +02:00
makiolaj 0828a01650 fix(i18n): give the French agenda_range_days plural its "other" form
Fixes the crash in #297 and #298. French resolves every count from 2
upwards to "other", so a custom agenda range of 2+ days threw
Resources$NotFoundException on the label — in the settings summary, in
the range picker, and live while typing a day count.

Corrected in Weblate first (unit 2849, whose third plural form was
stored empty), so this file and Weblate now agree and the next sync
cannot strip the item again.
2026-09-12 16:16:14 +02:00
makiolaj 8cd9a7b4e1 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.
2026-09-12 16:10:07 +02:00
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: