[icon_tree_shaker] Tree-shake material and cupertino with 0 icons (#190905)

Bug:

* https://github.com/flutter/flutter/issues/190902

Ensures built-in fonts are tree-shaken if 0 icons are used.
diff --git a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart
index 3f7356a..4e66092 100644
--- a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart
+++ b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart
@@ -141,9 +141,12 @@
       familyKeys,
     );
 
-    if (fonts.length != iconData.length) {
+    final Set<String> missingFonts = iconData.keys
+        .where((String key) => !fonts.containsKey(key))
+        .toSet();
+    if (missingFonts.isNotEmpty) {
       environment.logger.printStatus(
-        'Expected to find fonts for ${iconData.keys}, but found '
+        'Expected to find fonts for $missingFonts, but found '
         '${fonts.keys}. This usually means you are referring to '
         'font families in an IconData class but not including them '
         'in the assets section of your pubspec.yaml, are missing '
@@ -155,7 +158,9 @@
     final result = <String, _IconTreeShakerData>{};
     const kSpacePoint = 32;
     for (final MapEntry(:key, :value) in fonts.entries) {
-      final List<int>? codePoints = iconData[key];
+      final int? fallbackCodePoint = _kKnownIconFontFallbackCodePoints[key];
+      final List<int>? codePoints =
+          iconData[key] ?? (fallbackCodePoint != null ? <int>[fallbackCodePoint] : null);
       if (codePoints == null) {
         throw IconTreeShakerException._(
           'Expected to font code points for $key, but none were found.',
@@ -269,6 +274,14 @@
         'by providing the --no-tree-shake-icons flag when building your app.';
   }
 
+  /// Known icon font families that should be subsetted even if 0 icons are recorded.
+  /// Subsetting unused icon fonts to a single dummy icon ensures that unused fonts
+  /// are not bundled in their entirety.
+  static const Map<String, int> _kKnownIconFontFallbackCodePoints = <String, int>{
+    'MaterialIcons': 57415, // 0xe047, Icons.add
+    'packages/cupertino_icons/CupertinoIcons': 62418, // 0xf3d2, CupertinoIcons.chevron_left
+  };
+
   /// Returns a map of { fontFamily: relativePath } pairs.
   Future<Map<String, String>> _parseFontJson(String fontManifestData, Set<String> families) async {
     final result = <String, String>{};
@@ -285,7 +298,8 @@
           'got: ${map['family']}.',
         );
       }
-      if (!families.contains(familyKey)) {
+      if (!families.contains(familyKey) &&
+          !_kKnownIconFontFallbackCodePoints.containsKey(familyKey)) {
         continue;
       }
       final List<Map<String, Object?>> fonts = _getList(
diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart
index 6e66155..d8391d6 100644
--- a/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart
+++ b/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart
@@ -532,7 +532,12 @@
       targetPlatform: TargetPlatform.android,
     );
 
+    final stdinSink = CompleterIOSink();
     writeRecordedUsesFile(appDill.path, content: emptyRecordedUsesResult);
+    resetFontSubsetInvocation(stdinSink: stdinSink);
+    fileSystem.file(outputPath)
+      ..createSync(recursive: true)
+      ..writeAsBytesSync(List<int>.filled(1200, 0));
     // Does not throw
     await iconTreeShaker.subsetFont(
       input: fileSystem.file(inputPath),
@@ -540,6 +545,7 @@
       relativePath: relativePath,
     );
 
+    expect(stdinSink.getAndClear(), '57415\n');
     expect(
       logger.traceText,
       contains(
@@ -913,6 +919,114 @@
     expect(stdin, contains('59470'));
     expect(processManager, hasNoRemainingExpectations);
   });
+
+  testWithoutContext('Subsets unused CupertinoIcons font to fallback code point', () async {
+    final Environment environment = createEnvironment(<String, String>{
+      kIconTreeShakerFlag: 'true',
+      kBuildMode: 'release',
+    });
+    final File appDill = environment.buildDir.childFile('app.dill')..createSync(recursive: true);
+
+    const cupertinoFontPath = 'packages/cupertino_icons/assets/CupertinoIcons.ttf';
+    const cupertinoManifestJson =
+        '''
+[
+  {
+    "family": "packages/cupertino_icons/CupertinoIcons",
+    "fonts": [
+      {
+        "asset": "$cupertinoFontPath"
+      }
+    ]
+  }
+]
+''';
+    fontManifestContent = DevFSStringContent(cupertinoManifestJson);
+
+    final iconTreeShaker = IconTreeShaker(
+      environment,
+      fontManifestContent,
+      logger: logger,
+      processManager: processManager,
+      fileSystem: fileSystem,
+      artifacts: artifacts,
+      targetPlatform: TargetPlatform.android,
+    );
+
+    // Empty recordings (0 icons used)
+    writeRecordedUsesFile(appDill.path, content: emptyRecordedUsesResult);
+
+    final stdinSink = CompleterIOSink();
+    fontSubsetArgs = <String>[fontSubsetPath, outputPath, inputPath];
+    resetFontSubsetInvocation(stdinSink: stdinSink);
+
+    final File inputFont = fileSystem.file(inputPath)..writeAsBytesSync(List<int>.filled(2500, 0));
+    fileSystem.file(outputPath)
+      ..createSync(recursive: true)
+      ..writeAsBytesSync(List<int>.filled(1200, 0));
+
+    expect(
+      await iconTreeShaker.subsetFont(
+        input: inputFont,
+        outputPath: outputPath,
+        relativePath: cupertinoFontPath,
+      ),
+      true,
+    );
+
+    expect(stdinSink.getAndClear(), '62418\n');
+    expect(processManager, hasNoRemainingExpectations);
+  });
+
+  testWithoutContext('Does not subset unused non-icon font', () async {
+    final Environment environment = createEnvironment(<String, String>{
+      kIconTreeShakerFlag: 'true',
+      kBuildMode: 'release',
+    });
+    final File appDill = environment.buildDir.childFile('app.dill')..createSync(recursive: true);
+
+    const customFontPath = 'fonts/Roboto-Regular.ttf';
+    const customManifestJson =
+        '''
+[
+  {
+    "family": "Roboto",
+    "fonts": [
+      {
+        "asset": "$customFontPath"
+      }
+    ]
+  }
+]
+''';
+    fontManifestContent = DevFSStringContent(customManifestJson);
+
+    final iconTreeShaker = IconTreeShaker(
+      environment,
+      fontManifestContent,
+      logger: logger,
+      processManager: processManager,
+      fileSystem: fileSystem,
+      artifacts: artifacts,
+      targetPlatform: TargetPlatform.android,
+    );
+
+    // Empty recordings (0 icons used)
+    writeRecordedUsesFile(appDill.path, content: emptyRecordedUsesResult);
+
+    final File inputFont = fileSystem.file(inputPath)..writeAsBytesSync(List<int>.filled(2500, 0));
+
+    expect(
+      await iconTreeShaker.subsetFont(
+        input: inputFont,
+        outputPath: outputPath,
+        relativePath: customFontPath,
+      ),
+      false,
+    );
+
+    expect(processManager, hasNoRemainingExpectations);
+  });
 }
 
 const Library iconDataLibrary = Library('package:flutter/src/widgets/icon_data.dart');