misc .arb fixes; localizations validator (#12197)

* misc .arb fixes; localizations validator

* regenerate localizations

* address comments

* do not treat plural variations as invalid keys
diff --git a/dev/bots/test.dart b/dev/bots/test.dart
index a746a4d..c12ab27 100644
--- a/dev/bots/test.dart
+++ b/dev/bots/test.dart
@@ -52,8 +52,42 @@
   print('${bold}DONE: test.dart does nothing in the docs shard.$reset');
 }
 
+Future<Null> _verifyInternationalizations() async {
+  final EvalResult genResult = await _evalCommand(
+    dart,
+    <String>[
+      path.join('dev', 'tools', 'gen_localizations.dart'),
+      path.join('packages', 'flutter', 'lib', 'src', 'material', 'i18n'),
+      'material'
+    ],
+    workingDirectory: flutterRoot,
+  );
+
+  final String localizationsFile = path.join('packages', 'flutter', 'lib', 'src', 'material', 'i18n', 'localizations.dart');
+
+  final EvalResult sourceContents = await _evalCommand(
+    'cat',
+    <String>[localizationsFile],
+    workingDirectory: flutterRoot,
+  );
+
+  if (genResult.stdout.trim() != sourceContents.stdout.trim()) {
+    stderr
+      ..writeln('<<<<<<< $localizationsFile')
+      ..writeln(sourceContents.stdout.trim())
+      ..writeln('=======')
+      ..writeln(genResult.stdout.trim())
+      ..writeln('>>>>>>> gen_localizations')
+      ..writeln('The contents of $localizationsFile are different from that produced by gen_localizations.')
+      ..writeln()
+      ..writeln('Did you forget to run gen_localizations.dart after updating a .arb file?');
+    exit(1);
+  }
+}
+
 Future<Null> _analyzeRepo() async {
   await _verifyNoBadImports(flutterRoot);
+  await _verifyInternationalizations();
 
   // Analyze all the Dart code in the repo.
   await _runFlutterAnalyze(flutterRoot,
@@ -176,6 +210,57 @@
   return _runCommand(pub, args, workingDirectory: workingDirectory);
 }
 
+class EvalResult {
+  EvalResult({
+    this.stdout,
+    this.stderr,
+  });
+
+  final String stdout;
+  final String stderr;
+}
+
+Future<EvalResult> _evalCommand(String executable, List<String> arguments, {
+  String workingDirectory,
+  Map<String, String> environment,
+  bool skip: false,
+}) async {
+  final String commandDescription = '${path.relative(executable, from: workingDirectory)} ${arguments.join(' ')}';
+  final String relativeWorkingDir = path.relative(workingDirectory);
+  if (skip) {
+    _printProgress('SKIPPING', relativeWorkingDir, commandDescription);
+    return null;
+  }
+  _printProgress('RUNNING', relativeWorkingDir, commandDescription);
+
+  final Process process = await Process.start(executable, arguments,
+    workingDirectory: workingDirectory,
+    environment: environment,
+  );
+
+  final Future<List<List<int>>> savedStdout = process.stdout.toList();
+  final Future<List<List<int>>> savedStderr = process.stderr.toList();
+  final int exitCode = await process.exitCode;
+  final EvalResult result = new EvalResult(
+    stdout: UTF8.decode((await savedStdout).expand((List<int> ints) => ints).toList()),
+    stderr: UTF8.decode((await savedStderr).expand((List<int> ints) => ints).toList()),
+  );
+
+  if (exitCode != 0) {
+    stderr.write(result.stderr);
+    print(
+      '$red━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$reset\n'
+      '${bold}ERROR:$red Last command exited with $exitCode.$reset\n'
+      '${bold}Command:$red $commandDescription$reset\n'
+      '${bold}Relative working directory:$red $relativeWorkingDir$reset\n'
+      '$red━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$reset'
+    );
+    exit(1);
+  }
+
+  return result;
+}
+
 Future<Null> _runCommand(String executable, List<String> arguments, {
   String workingDirectory,
   Map<String, String> environment,
diff --git a/dev/tools/gen_localizations.dart b/dev/tools/gen_localizations.dart
index 493044b..b3e9e61 100644
--- a/dev/tools/gen_localizations.dart
+++ b/dev/tools/gen_localizations.dart
@@ -26,6 +26,8 @@
 import 'dart:convert' show JSON;
 import 'dart:io';
 
+import 'localizations_validator.dart';
+
 const String outputHeader = '''
 // Copyright 2017 The Chromium Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
@@ -36,8 +38,14 @@
 // @(regenerate)
 ''';
 
+/// Maps locales to resource key/value pairs.
 final Map<String, Map<String, String>> localeToResources = <String, Map<String, String>>{};
 
+/// Maps locales to resource attributes.
+/// 
+/// See also https://github.com/googlei18n/app-resource-bundle/wiki/ApplicationResourceBundleSpecification#resource-attributes
+final Map<String, Map<String, dynamic>> localeToResourceAttributes = <String, Map<String, dynamic>>{};
+
 // Return s as a Dart-parseable raw string in double quotes. Expand double quotes:
 // foo => r"foo"
 // foo "bar" => r"foo " '"' r"bar" '"'
@@ -92,13 +100,16 @@
 
 void processBundle(File file, String locale) {
   localeToResources[locale] ??= <String, String>{};
+  localeToResourceAttributes[locale] ??= <String, dynamic>{};
   final Map<String, String> resources = localeToResources[locale];
+  final Map<String, dynamic> attributes = localeToResourceAttributes[locale];
   final Map<String, dynamic> bundle = JSON.decode(file.readAsStringSync());
   for (String key in bundle.keys) {
     // The ARB file resource "attributes" for foo are called @foo.
     if (key.startsWith('@'))
-      continue;
-    resources[key] = bundle[key];
+      attributes[key.substring(1)] = bundle[key];
+    else
+      resources[key] = bundle[key];
   }
 }
 
@@ -121,6 +132,7 @@
       processBundle(new File(path), locale);
     }
   }
+  validateLocalizations(localeToResources, localeToResourceAttributes);
 
   final String regenerate = 'dart dev/tools/gen_localizations.dart ${directory.path} ${args[1]}';
   print(outputHeader.replaceFirst('@(regenerate)', regenerate));
diff --git a/dev/tools/localizations_validator.dart b/dev/tools/localizations_validator.dart
new file mode 100644
index 0000000..90aec93
--- /dev/null
+++ b/dev/tools/localizations_validator.dart
@@ -0,0 +1,91 @@
+// Copyright 2017 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:io';
+
+/// Enforces the following invariants in our localizations:
+/// 
+/// - Resource keys are valid, i.e. they appear in the canonical list.
+/// - Resource keys are complete for language-level locales, e.g. "es", "he".
+/// 
+/// Uses "en" localizations as the canonical source of locale keys that other
+/// locales are compared against.
+/// 
+/// If validation fails, print an error message to STDERR and quit with exit
+/// code 1.
+void validateLocalizations(
+  Map<String, Map<String, String>> localeToResources,
+  Map<String, Map<String, dynamic>> localeToAttributes,
+) {
+  final Map<String, String> canonicalLocalizations = localeToResources['en'];
+  final Set<String> canonicalKeys = new Set<String>.from(canonicalLocalizations.keys);
+  final StringBuffer errorMessages = new StringBuffer();
+  bool explainMissingKeys = false;
+  for (final String locale in localeToResources.keys) {
+    final Map<String, String> resources = localeToResources[locale];
+
+    // Whether `key` corresponds to one of the plural variations of a key with
+    // the same prefix and suffix "Other".
+    //
+    // Many languages require only a subset of these variations, so we do not
+    // require them so long as the "Other" variation exists.
+    bool isPluralVariation(String key) {
+      final RegExp pluralRegexp = new RegExp(r'(\w*)(Zero|One|Two|Few|Many)$');
+      final Match pluralMatch = pluralRegexp.firstMatch(key);
+      
+      if (pluralMatch == null)
+        return false;
+
+      final String prefix = pluralMatch[1];
+      return resources.containsKey('${prefix}Other');
+    }
+
+    final Set<String> keys = new Set<String>.from(
+      resources.keys.where((String key) => !isPluralVariation(key))
+    );
+
+    // Make sure keys are valid (i.e. they also exist in the canonical
+    // localizations)
+    final Set<String> invalidKeys = keys.difference(canonicalKeys);
+    if (invalidKeys.isNotEmpty)
+      errorMessages.writeln('Locale "$locale" contains invalid resource keys: ${invalidKeys.join(', ')}');
+
+    // For language-level locales only, check that they have a complete list of
+    // keys, or opted out of using certain ones.
+    if (locale.length == 2) {
+      final Map<String, dynamic> attributes = localeToAttributes[locale];
+      final List<String> missingKeys = <String>[];
+
+      for (final String missingKey in canonicalKeys.difference(keys)) {
+        final dynamic attribute = attributes[missingKey];
+        final bool intentionallyOmitted = attribute is Map && attribute.containsKey('notUsed');
+        if (!intentionallyOmitted && !isPluralVariation(missingKey))
+          missingKeys.add(missingKey);
+      }
+      if (missingKeys.isNotEmpty) {
+        explainMissingKeys = true;
+        errorMessages.writeln('Locale "$locale" is missing the following resource keys: ${missingKeys.join(', ')}');
+      }
+    }
+  }
+
+  if (errorMessages.isNotEmpty) {
+    if (explainMissingKeys) {
+        errorMessages
+          ..writeln()
+          ..writeln(
+            'If a resource key is intentionally omitted, add an attribute corresponding '
+            'to the key name with a "notUsed" property explaining why. Example:'
+          )
+          ..writeln()
+          ..writeln('"@anteMeridiemAbbreviation": {')
+          ..writeln('  "notUsed": "Sindhi time format does not use a.m. indicator"')
+          ..writeln('}');
+    }
+
+    stderr.writeln('ERROR:');
+    stderr.writeln(errorMessages);
+    exit(1);
+  }
+}
diff --git a/packages/flutter/lib/src/material/i18n/localizations.dart b/packages/flutter/lib/src/material/i18n/localizations.dart
index 496641b..ce972ac 100644
--- a/packages/flutter/lib/src/material/i18n/localizations.dart
+++ b/packages/flutter/lib/src/material/i18n/localizations.dart
@@ -4,7 +4,7 @@
 
 // This file has been automatically generated.  Please do not edit it manually.
 // To regenerate the file, use:
-// dart dev/tools/gen_localizations.dart lib/src/material/i18n material
+// dart dev/tools/gen_localizations.dart packages/flutter/lib/src/material/i18n material
 
 /// Maps from [Locale.languageCode] to a map that contains the localized strings
 /// for that locale.
@@ -136,6 +136,8 @@
   },
   "es_US": const <String, String>{
     "timeOfDayFormat": r"h:mm a",
+    "anteMeridiemAbbreviation": r"AM",
+    "postMeridiemAbbreviation": r"PM",
   },
   "fa": const <String, String>{
     "timeOfDayFormat": r"H:mm",
@@ -339,7 +341,9 @@
     "rowsPerPageTitle": r"Строки на страницу:",
     "aboutListTileTitle": r"O $applicationName",
     "licensesPageTitle": r"Лицензии",
-    "selectedRowCountTitleOther": r"Выбранно $selectedRowCount строк",
+    "selectedRowCountTitleZero": r"Строки не выбраны",
+    "selectedRowCountTitleOne": r"Выбрана 1 строка",
+    "selectedRowCountTitleOther": r"Выбрано $selectedRowCount строк",
     "cancelButtonLabel": r"ОТМЕНИТЬ",
     "closeButtonLabel": r"ЗАКРЫТЬ",
     "continueButtonLabel": r"ПРОДОЛЖИТЬ",
diff --git a/packages/flutter/lib/src/material/i18n/material_de.arb b/packages/flutter/lib/src/material/i18n/material_de.arb
index e85f798..6d05c7e 100644
--- a/packages/flutter/lib/src/material/i18n/material_de.arb
+++ b/packages/flutter/lib/src/material/i18n/material_de.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "German time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "German time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Navigationsmenü öffnen",
   "backButtonTooltip": "Zurück",
   "closeButtonTooltip": "Schließen",
diff --git a/packages/flutter/lib/src/material/i18n/material_en.arb b/packages/flutter/lib/src/material/i18n/material_en.arb
index fd29893..6d7e42f 100644
--- a/packages/flutter/lib/src/material/i18n/material_en.arb
+++ b/packages/flutter/lib/src/material/i18n/material_en.arb
@@ -67,7 +67,7 @@
 
   "pageRowsInfoTitle": "$firstRow–$lastRow of $rowCount",
   "pageRowsInfoTitleApproximate": "$firstRow–$lastRow of about $rowCount",
-  "@pageRowInfoTitle": {
+  "@pageRowsInfoTitle": {
     "description": "Title for the [PaginatedDataTable]'s row info footer",
     "type": "text"
   },
diff --git a/packages/flutter/lib/src/material/i18n/material_es.arb b/packages/flutter/lib/src/material/i18n/material_es.arb
index fe7e2f7..a6371ed 100644
--- a/packages/flutter/lib/src/material/i18n/material_es.arb
+++ b/packages/flutter/lib/src/material/i18n/material_es.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "H:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Standard Spanish time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Standard Spanish time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Abrir el menú de navegación",
   "backButtonTooltip": "Espalda",
   "closeButtonTooltip": "Cerrar",
diff --git a/packages/flutter/lib/src/material/i18n/material_es_US.arb b/packages/flutter/lib/src/material/i18n/material_es_US.arb
index 1de168c..95c984c 100644
--- a/packages/flutter/lib/src/material/i18n/material_es_US.arb
+++ b/packages/flutter/lib/src/material/i18n/material_es_US.arb
@@ -1,3 +1,5 @@
 {
-  "timeOfDayFormat": "h:mm a"
+  "timeOfDayFormat": "h:mm a",
+  "anteMeridiemAbbreviation": "AM",
+  "postMeridiemAbbreviation": "PM"
 }
diff --git a/packages/flutter/lib/src/material/i18n/material_fa.arb b/packages/flutter/lib/src/material/i18n/material_fa.arb
index 2c1303a..799e727 100644
--- a/packages/flutter/lib/src/material/i18n/material_fa.arb
+++ b/packages/flutter/lib/src/material/i18n/material_fa.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "H:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Farsi time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Farsi time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "منوی ناوبری را باز کنید",
   "backButtonTooltip": "بازگشت",
   "closeButtonTooltip": "بستن",
diff --git a/packages/flutter/lib/src/material/i18n/material_fr.arb b/packages/flutter/lib/src/material/i18n/material_fr.arb
index 70083c9..80c1766 100644
--- a/packages/flutter/lib/src/material/i18n/material_fr.arb
+++ b/packages/flutter/lib/src/material/i18n/material_fr.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "French time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "French time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Ouvrir le menu de navigation",
   "backButtonTooltip": "Retour",
   "closeButtonTooltip": "Fermer",
diff --git a/packages/flutter/lib/src/material/i18n/material_he.arb b/packages/flutter/lib/src/material/i18n/material_he.arb
index 17ee50a..da02eb7 100644
--- a/packages/flutter/lib/src/material/i18n/material_he.arb
+++ b/packages/flutter/lib/src/material/i18n/material_he.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "H:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Hebrew time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Hebrew time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "פתח תפריט ניווט",
   "backButtonTooltip": "אחורה",
   "closeButtonTooltip": "סגור",
diff --git a/packages/flutter/lib/src/material/i18n/material_it.arb b/packages/flutter/lib/src/material/i18n/material_it.arb
index c23c004..9ff289d 100644
--- a/packages/flutter/lib/src/material/i18n/material_it.arb
+++ b/packages/flutter/lib/src/material/i18n/material_it.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Italian time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Italian time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Apri il menu di navigazione",
   "backButtonTooltip": "Indietro",
   "closeButtonTooltip": "Chiudi",
diff --git a/packages/flutter/lib/src/material/i18n/material_ja.arb b/packages/flutter/lib/src/material/i18n/material_ja.arb
index 9f4d5f3..574f058 100644
--- a/packages/flutter/lib/src/material/i18n/material_ja.arb
+++ b/packages/flutter/lib/src/material/i18n/material_ja.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "H:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Japanese time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Japanese time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "ナビゲーションメニューを開く",
   "backButtonTooltip": "戻る",
   "closeButtonTooltip": "閉じる",
diff --git a/packages/flutter/lib/src/material/i18n/material_ps.arb b/packages/flutter/lib/src/material/i18n/material_ps.arb
index 010bde3..f1e3d23 100644
--- a/packages/flutter/lib/src/material/i18n/material_ps.arb
+++ b/packages/flutter/lib/src/material/i18n/material_ps.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Pashto time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Pashto time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "د پرانیستی نیینګ مینو",
   "backButtonTooltip": "شاته",
   "closeButtonTooltip": "بنده",
diff --git a/packages/flutter/lib/src/material/i18n/material_pt.arb b/packages/flutter/lib/src/material/i18n/material_pt.arb
index 15deeb4..6f17d41 100644
--- a/packages/flutter/lib/src/material/i18n/material_pt.arb
+++ b/packages/flutter/lib/src/material/i18n/material_pt.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Portuguese time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Portuguese time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Abrir menu de navegação",
   "backButtonTooltip": "Costas",
   "closeButtonTooltip": "Fechar",
diff --git a/packages/flutter/lib/src/material/i18n/material_ru.arb b/packages/flutter/lib/src/material/i18n/material_ru.arb
index 2ac9fd6..985941e 100644
--- a/packages/flutter/lib/src/material/i18n/material_ru.arb
+++ b/packages/flutter/lib/src/material/i18n/material_ru.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "H:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Russian time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Russian time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "Открыть меню навигации",
   "backButtonTooltip": "Назад",
   "closeButtonTooltip": "Закрыть",
@@ -13,7 +15,9 @@
   "rowsPerPageTitle": "Строки на страницу:",
   "aboutListTileTitle": "O $applicationName",
   "licensesPageTitle": "Лицензии",
-  "selectedRowCountTitleOther": "Выбранно $selectedRowCount строк",
+  "selectedRowCountTitleZero": "Строки не выбраны",
+  "selectedRowCountTitleOne": "Выбрана 1 строка",
+  "selectedRowCountTitleOther": "Выбрано $selectedRowCount строк",
   "cancelButtonLabel": "ОТМЕНИТЬ",
   "closeButtonLabel": "ЗАКРЫТЬ",
   "continueButtonLabel": "ПРОДОЛЖИТЬ",
diff --git a/packages/flutter/lib/src/material/i18n/material_sd.arb b/packages/flutter/lib/src/material/i18n/material_sd.arb
index 903f972..09f560c 100644
--- a/packages/flutter/lib/src/material/i18n/material_sd.arb
+++ b/packages/flutter/lib/src/material/i18n/material_sd.arb
@@ -1,5 +1,7 @@
 {
   "timeOfDayFormat": "HH:mm",
+  "@anteMeridiemAbbreviation": { "notUsed": "Sindhi time format does not use a.m. indicator" },
+  "@postMeridiemAbbreviation": { "notUsed": "Sindhi time format does not use p.m. indicator" },
   "openAppDrawerTooltip": "اوپن جي مينڊيٽ مينيو",
   "backButtonTooltip": "پوئتي",
   "closeButtonTooltip": "بند ڪريو",