Fully support Dart-only mobile and macOS plugins (#96183)

diff --git a/dev/devicelab/bin/tasks/module_test_ios.dart b/dev/devicelab/bin/tasks/module_test_ios.dart
index ab646eb..81f69bf 100644
--- a/dev/devicelab/bin/tasks/module_test_ios.dart
+++ b/dev/devicelab/bin/tasks/module_test_ios.dart
@@ -152,14 +152,28 @@
         await flutter('clean');
       });
 
+      // Make a fake Dart-only plugin, since there are no existing examples.
+      section('Create local plugin');
+
+      const String dartPluginName = 'dartplugin';
+      await _createFakeDartPlugin(dartPluginName, tempDir);
+
       section('Add plugins');
 
       final File pubspec = File(path.join(projectDir.path, 'pubspec.yaml'));
       String content = await pubspec.readAsString();
       content = content.replaceFirst(
         '\ndependencies:\n',
-        // One dynamic framework, one static framework, and one that does not support iOS.
-        '\ndependencies:\n  device_info: 2.0.3\n  google_sign_in: 4.5.1\n  android_alarm_manager: 0.4.5+11\n',
+        // One dynamic framework, one static framework, one Dart-only,
+        // and one that does not support iOS.
+        '''
+dependencies:
+  device_info: 2.0.3
+  google_sign_in: 4.5.1
+  android_alarm_manager: 0.4.5+11
+  $dartPluginName:
+    path: ../$dartPluginName
+''',
       );
       await pubspec.writeAsString(content, flush: true);
       await inDirectory(projectDir, () async {
@@ -191,7 +205,8 @@
         || !podfileLockOutput.contains(':path: Flutter/FlutterPluginRegistrant')
         || !podfileLockOutput.contains(':path: ".symlinks/plugins/device_info/ios"')
         || !podfileLockOutput.contains(':path: ".symlinks/plugins/google_sign_in/ios"')
-        || podfileLockOutput.contains('android_alarm_manager')) {
+        || podfileLockOutput.contains('android_alarm_manager')
+        || podfileLockOutput.contains(dartPluginName)) {
         print(podfileLockOutput);
         return TaskResult.failure('Building ephemeral host app Podfile.lock does not contain expected pods');
       }
@@ -205,6 +220,9 @@
       // Android-only, no embedded framework.
       checkDirectoryNotExists(path.join(ephemeralIOSHostApp.path, 'Frameworks', 'android_alarm_manager.framework'));
 
+      // Dart-only, no embedded framework.
+      checkDirectoryNotExists(path.join(ephemeralIOSHostApp.path, 'Frameworks', '$dartPluginName.framework'));
+
       section('Clean and pub get module');
 
       await inDirectory(projectDir, () async {
@@ -243,7 +261,8 @@
             || !hostPodfileLockOutput.contains(':path: "../hello/.ios/Flutter/FlutterPluginRegistrant"')
             || !hostPodfileLockOutput.contains(':path: "../hello/.ios/.symlinks/plugins/device_info/ios"')
             || !hostPodfileLockOutput.contains(':path: "../hello/.ios/.symlinks/plugins/google_sign_in/ios"')
-            || hostPodfileLockOutput.contains('android_alarm_manager')) {
+            || hostPodfileLockOutput.contains('android_alarm_manager')
+            || hostPodfileLockOutput.contains(dartPluginName)) {
           print(hostPodfileLockOutput);
           throw TaskResult.failure('Building host app Podfile.lock does not contain expected pods');
         }
@@ -501,3 +520,46 @@
 
   return symbolTable.contains('kDartIsolateSnapshotInstructions');
 }
+
+Future<void> _createFakeDartPlugin(String name, Directory parent) async {
+  // Start from a standard plugin template.
+  await inDirectory(parent, () async {
+    await flutter(
+      'create',
+      options: <String>[
+        '--org',
+        'io.flutter.devicelab',
+        '--template=plugin',
+        '--platforms=ios',
+        name,
+      ],
+    );
+  });
+
+  final String pluginDir = path.join(parent.path, name);
+
+  // Convert the metadata to Dart-only.
+  final String dartPluginClass = 'DartClassFor$name';
+  final File pubspec = File(path.join(pluginDir, 'pubspec.yaml'));
+  String content = await pubspec.readAsString();
+  content = content.replaceAll(
+    RegExp(r' pluginClass: .*?\n'),
+    ' dartPluginClass: $dartPluginClass\n',
+  );
+  await pubspec.writeAsString(content, flush: true);
+
+  // Add the Dart registration hook that the build will generate a call to.
+  final File dartCode = File(path.join(pluginDir, 'lib', '$name.dart'));
+  content = await dartCode.readAsString();
+  content = '''
+$content
+
+class $dartPluginClass {
+  static void registerWith() {}
+}
+''';
+  await dartCode.writeAsString(content, flush: true);
+
+  // Remove the native plugin code.
+  await Directory(path.join(pluginDir, 'ios')).delete(recursive: true);
+}
diff --git a/dev/devicelab/bin/tasks/plugin_test.dart b/dev/devicelab/bin/tasks/plugin_test.dart
index d7b5e53..9b556cd 100644
--- a/dev/devicelab/bin/tasks/plugin_test.dart
+++ b/dev/devicelab/bin/tasks/plugin_test.dart
@@ -16,5 +16,7 @@
         <String, String>{'ENABLE_ANDROID_EMBEDDING_V2': 'true'}),
     PluginTest('apk', <String>['-a', 'kotlin', '--platforms=android'], pluginCreateEnvironment:
         <String, String>{'ENABLE_ANDROID_EMBEDDING_V2': 'true'}),
+    // Test that Dart-only plugins are supported.
+    PluginTest('apk', <String>['--platforms=android'], dartOnlyPlugin: true),
   ]));
 }
diff --git a/dev/devicelab/bin/tasks/plugin_test_ios.dart b/dev/devicelab/bin/tasks/plugin_test_ios.dart
index bff25a2..408cf1b 100644
--- a/dev/devicelab/bin/tasks/plugin_test_ios.dart
+++ b/dev/devicelab/bin/tasks/plugin_test_ios.dart
@@ -10,5 +10,8 @@
     PluginTest('ios', <String>['-i', 'objc', '--platforms=ios']),
     PluginTest('ios', <String>['-i', 'swift', '--platforms=ios']),
     PluginTest('macos', <String>['--platforms=macos']),
+    // Test that Dart-only plugins are supported.
+    PluginTest('ios', <String>['--platforms=ios'], dartOnlyPlugin: true),
+    PluginTest('macos', <String>['--platforms=macos'], dartOnlyPlugin: true),
   ]));
 }
diff --git a/dev/devicelab/lib/tasks/plugin_tests.dart b/dev/devicelab/lib/tasks/plugin_tests.dart
index 05fc551..4de88a2 100644
--- a/dev/devicelab/lib/tasks/plugin_tests.dart
+++ b/dev/devicelab/lib/tasks/plugin_tests.dart
@@ -26,12 +26,19 @@
 /// Defines task that creates new Flutter project, adds a local and remote
 /// plugin, and then builds the specified [buildTarget].
 class PluginTest {
-  PluginTest(this.buildTarget, this.options, { this.pluginCreateEnvironment, this.appCreateEnvironment });
+  PluginTest(
+    this.buildTarget,
+    this.options, {
+    this.pluginCreateEnvironment,
+    this.appCreateEnvironment,
+    this.dartOnlyPlugin = false,
+  });
 
   final String buildTarget;
   final List<String> options;
   final Map<String, String>? pluginCreateEnvironment;
   final Map<String, String>? appCreateEnvironment;
+  final bool dartOnlyPlugin;
 
   Future<TaskResult> call() async {
     final Directory tempDir =
@@ -41,6 +48,9 @@
       final _FlutterProject plugin = await _FlutterProject.create(
           tempDir, options, buildTarget,
           name: 'plugintest', template: 'plugin', environment: pluginCreateEnvironment);
+      if (dartOnlyPlugin) {
+        await plugin.convertDefaultPluginToDartPlugin();
+      }
       section('Test plugin');
       await plugin.test();
       section('Create Flutter app');
@@ -52,7 +62,7 @@
             pluginPath: path.join('..', 'plugintest'));
         await app.addPlugin('path_provider');
         section('Build app');
-        await app.build(buildTarget);
+        await app.build(buildTarget, validateNativeBuildProject: !dartOnlyPlugin);
         section('Test app');
         await app.test();
       } finally {
@@ -76,8 +86,10 @@
 
   String get rootPath => path.join(parent.path, name);
 
+  File get pubspecFile => File(path.join(rootPath, 'pubspec.yaml'));
+
   Future<void> addPlugin(String plugin, {String? pluginPath}) async {
-    final File pubspec = File(path.join(rootPath, 'pubspec.yaml'));
+    final File pubspec = pubspecFile;
     String content = await pubspec.readAsString();
     final String dependency =
         pluginPath != null ? '$plugin:\n    path: $pluginPath' : '$plugin:';
@@ -88,6 +100,47 @@
     await pubspec.writeAsString(content, flush: true);
   }
 
+  /// Converts a plugin created from the standard template to a Dart-only
+  /// plugin.
+  Future<void> convertDefaultPluginToDartPlugin() async {
+    final String dartPluginClass = 'DartClassFor$name';
+    // Convert the metadata.
+    final File pubspec = pubspecFile;
+    String content = await pubspec.readAsString();
+    content = content.replaceAll(
+      RegExp(r' pluginClass: .*?\n'),
+      ' dartPluginClass: $dartPluginClass\n',
+    );
+    await pubspec.writeAsString(content, flush: true);
+
+    // Add the Dart registration hook that the build will generate a call to.
+    final File dartCode = File(path.join(rootPath, 'lib', '$name.dart'));
+    content = await dartCode.readAsString();
+    content = '''
+$content
+
+class $dartPluginClass {
+  static void registerWith() {}
+}
+''';
+    await dartCode.writeAsString(content, flush: true);
+
+    // Remove any native plugin code.
+    const List<String> platforms = <String>[
+      'android',
+      'ios',
+      'linux',
+      'macos',
+      'windows',
+    ];
+    for (final String platform in platforms) {
+      final Directory platformDir = Directory(path.join(rootPath, platform));
+      if (platformDir.existsSync()) {
+        await platformDir.delete(recursive: true);
+      }
+    }
+  }
+
   Future<void> test() async {
     await inDirectory(Directory(rootPath), () async {
       await flutter('test');
@@ -147,7 +200,7 @@
     podspec.writeAsStringSync(podspecContent, flush: true);
   }
 
-  Future<void> build(String target) async {
+  Future<void> build(String target, {bool validateNativeBuildProject = true}) async {
     await inDirectory(Directory(rootPath), () async {
       final String buildOutput =  await evalFlutter('build', options: <String>[
         target,
@@ -167,28 +220,30 @@
           throw TaskResult.failure('Minimum plugin version warning present');
         }
 
-        final File podsProject = File(path.join(rootPath, target, 'Pods', 'Pods.xcodeproj', 'project.pbxproj'));
-        if (!podsProject.existsSync()) {
-          throw TaskResult.failure('Xcode Pods project file missing at ${podsProject.path}');
-        }
-
-        final String podsProjectContent = podsProject.readAsStringSync();
-        if (target == 'ios') {
-          // Plugins with versions lower than the app version should not have IPHONEOS_DEPLOYMENT_TARGET set.
-          // The plugintest plugin target should not have IPHONEOS_DEPLOYMENT_TARGET set since it has been lowered
-          // in _reduceDarwinPluginMinimumVersion to 7, which is below the target version of 9.
-          if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 7')) {
-            throw TaskResult.failure('Plugin build setting IPHONEOS_DEPLOYMENT_TARGET not removed');
+        if (validateNativeBuildProject) {
+          final File podsProject = File(path.join(rootPath, target, 'Pods', 'Pods.xcodeproj', 'project.pbxproj'));
+          if (!podsProject.existsSync()) {
+            throw TaskResult.failure('Xcode Pods project file missing at ${podsProject.path}');
           }
-          if (!podsProjectContent.contains(r'"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited) i386";')) {
-            throw TaskResult.failure(r'EXCLUDED_ARCHS is not "$(inherited) i386"');
-          }
-        }
 
-        // Same for macOS deployment target, but 10.8.
-        // The plugintest target should not have MACOSX_DEPLOYMENT_TARGET set.
-        if (target == 'macos' && podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.8')) {
-          throw TaskResult.failure('Plugin build setting MACOSX_DEPLOYMENT_TARGET not removed');
+          final String podsProjectContent = podsProject.readAsStringSync();
+          if (target == 'ios') {
+            // Plugins with versions lower than the app version should not have IPHONEOS_DEPLOYMENT_TARGET set.
+            // The plugintest plugin target should not have IPHONEOS_DEPLOYMENT_TARGET set since it has been lowered
+            // in _reduceDarwinPluginMinimumVersion to 7, which is below the target version of 9.
+            if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 7')) {
+              throw TaskResult.failure('Plugin build setting IPHONEOS_DEPLOYMENT_TARGET not removed');
+            }
+            if (!podsProjectContent.contains(r'"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited) i386";')) {
+              throw TaskResult.failure(r'EXCLUDED_ARCHS is not "$(inherited) i386"');
+            }
+          }
+
+          // Same for macOS deployment target, but 10.8.
+          // The plugintest target should not have MACOSX_DEPLOYMENT_TARGET set.
+          if (target == 'macos' && podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.8')) {
+            throw TaskResult.failure('Plugin build setting MACOSX_DEPLOYMENT_TARGET not removed');
+          }
         }
       }
     });
diff --git a/packages/flutter_tools/bin/podhelper.rb b/packages/flutter_tools/bin/podhelper.rb
index 97e073d..7f11888 100644
--- a/packages/flutter_tools/bin/podhelper.rb
+++ b/packages/flutter_tools/bin/podhelper.rb
@@ -255,7 +255,8 @@
   plugin_pods.each do |plugin_hash|
     plugin_name = plugin_hash['name']
     plugin_path = plugin_hash['path']
-    if (plugin_name && plugin_path)
+    has_native_build = plugin_hash.fetch('native_build', true)
+    if (plugin_name && plugin_path && has_native_build)
       symlink = File.join(symlink_plugins_dir, plugin_name)
       File.symlink(plugin_path, symlink)
 
diff --git a/packages/flutter_tools/gradle/app_plugin_loader.gradle b/packages/flutter_tools/gradle/app_plugin_loader.gradle
index f722ea8..ed92e8e 100644
--- a/packages/flutter_tools/gradle/app_plugin_loader.gradle
+++ b/packages/flutter_tools/gradle/app_plugin_loader.gradle
@@ -23,6 +23,12 @@
 object.plugins.android.each { androidPlugin ->
   assert androidPlugin.name instanceof String
   assert androidPlugin.path instanceof String
+  // Skip plugins that have no native build (such as a Dart-only implementation
+  // of a federated plugin).
+  def needsBuild = androidPlugin.containsKey('native_build') ? androidPlugin['native_build'] : true
+  if (!needsBuild) {
+    return
+  }
   def pluginDirectory = new File(androidPlugin.path, 'android')
   assert pluginDirectory.exists()
   include ":${androidPlugin.name}"
diff --git a/packages/flutter_tools/gradle/module_plugin_loader.gradle b/packages/flutter_tools/gradle/module_plugin_loader.gradle
index ebce109..d0b7287 100644
--- a/packages/flutter_tools/gradle/module_plugin_loader.gradle
+++ b/packages/flutter_tools/gradle/module_plugin_loader.gradle
@@ -20,6 +20,12 @@
     object.plugins.android.each { androidPlugin ->
         assert androidPlugin.name instanceof String
         assert androidPlugin.path instanceof String
+        // Skip plugins that have no native build (such as a Dart-only
+        // implementation of a federated plugin).
+        def needsBuild = androidPlugin.containsKey('native_build') ? androidPlugin['native_build'] : true
+        if (!needsBuild) {
+            return
+        }
         def pluginDirectory = new File(androidPlugin.path, 'android')
         assert pluginDirectory.exists()
         include ":${androidPlugin.name}"
diff --git a/packages/flutter_tools/lib/src/flutter_plugins.dart b/packages/flutter_tools/lib/src/flutter_plugins.dart
index 65a845e..2520df5 100644
--- a/packages/flutter_tools/lib/src/flutter_plugins.dart
+++ b/packages/flutter_tools/lib/src/flutter_plugins.dart
@@ -98,6 +98,7 @@
 const String _kFlutterPluginsNameKey = 'name';
 const String _kFlutterPluginsPathKey = 'path';
 const String _kFlutterPluginsDependenciesKey = 'dependencies';
+const String _kFlutterPluginsHasNativeBuildKey = 'native_build';
 
 /// Filters [plugins] to those supported by [platformKey].
 List<Map<String, Object>> _filterPluginsByPlatform(List<Plugin> plugins, String platformKey) {
@@ -108,9 +109,13 @@
   final Set<String> pluginNames = platformPlugins.map((Plugin plugin) => plugin.name).toSet();
   final List<Map<String, Object>> pluginInfo = <Map<String, Object>>[];
   for (final Plugin plugin in platformPlugins) {
+    // This is guaranteed to be non-null due to the `where` filter above.
+    final PluginPlatform platformPlugin = plugin.platforms[platformKey]!;
     pluginInfo.add(<String, Object>{
       _kFlutterPluginsNameKey: plugin.name,
       _kFlutterPluginsPathKey: globals.fsUtils.escapePath(plugin.path),
+      if (platformPlugin is NativeOrDartPlugin)
+        _kFlutterPluginsHasNativeBuildKey: (platformPlugin as NativeOrDartPlugin).isNative(),
       _kFlutterPluginsDependenciesKey: <String>[...plugin.dependencies.where(pluginNames.contains)],
     });
   }
@@ -130,7 +135,8 @@
 ///         "dependencies": [
 ///           "plugin-a",
 ///           "plugin-b"
-///         ]
+///         ],
+///         "native_build": true
 ///       }
 ///     ],
 ///     "android": [],
diff --git a/packages/flutter_tools/templates/module/ios/library/Flutter.tmpl/podhelper.rb.tmpl b/packages/flutter_tools/templates/module/ios/library/Flutter.tmpl/podhelper.rb.tmpl
index 2f3d685..2dbaaaa 100644
--- a/packages/flutter_tools/templates/module/ios/library/Flutter.tmpl/podhelper.rb.tmpl
+++ b/packages/flutter_tools/templates/module/ios/library/Flutter.tmpl/podhelper.rb.tmpl
@@ -81,7 +81,8 @@
   plugin_pods.each do |plugin_hash|
     plugin_name = plugin_hash['name']
     plugin_path = plugin_hash['path']
-    if (plugin_name && plugin_path)
+    has_native_build = plugin_hash.fetch('native_build', true)
+    if (plugin_name && plugin_path && has_native_build)
       symlink = File.join(symlinks_dir, plugin_name)
       FileUtils.rm_f(symlink)
       File.symlink(plugin_path, symlink)
diff --git a/packages/flutter_tools/test/general.shard/plugins_test.dart b/packages/flutter_tools/test/general.shard/plugins_test.dart
index da38436..a51ffc8 100644
--- a/packages/flutter_tools/test/general.shard/plugins_test.dart
+++ b/packages/flutter_tools/test/general.shard/plugins_test.dart
@@ -30,6 +30,46 @@
 import '../src/fakes.dart' hide FakeOperatingSystemUtils;
 import '../src/pubspec_schema.dart';
 
+/// Information for a platform entry in the 'platforms' section of a plugin's
+/// pubspec.yaml.
+class _PluginPlatformInfo {
+  const _PluginPlatformInfo({
+    this.pluginClass,
+    this.dartPluginClass,
+    this.androidPackage,
+    this.fileName
+  }) : assert(pluginClass != null || dartPluginClass != null),
+       assert(androidPackage == null || pluginClass != null);
+
+  /// The pluginClass entry, if any.
+  final String pluginClass;
+
+  /// The dartPluginClass entry, if any.
+  final String dartPluginClass;
+
+  /// The package entry for an Android plugin implementation using pluginClass.
+  final String androidPackage;
+
+  /// The fileName entry for a web plugin implementation.
+  final String fileName;
+
+  /// Returns the body of a platform section for a plugin's pubspec, properly
+  /// indented.
+  String get indentedPubspecSection {
+    const String indentation = '        ';
+    return <String>[
+      if (pluginClass != null)
+        '${indentation}pluginClass: $pluginClass',
+      if (dartPluginClass != null)
+        '${indentation}dartPluginClass: $dartPluginClass',
+      if (androidPackage != null)
+        '${indentation}package: $androidPackage',
+      if (fileName != null)
+        '${indentation}fileName: $fileName',
+    ].join('\n');
+  }
+}
+
 void main() {
   group('plugins', () {
     FileSystem fs;
@@ -331,7 +371,7 @@
         );
     }
 
-    Directory createPluginWithDependencies({
+    Directory createLegacyPluginWithDependencies({
       @required String name,
       @required List<String> dependencies,
     }) {
@@ -363,6 +403,44 @@
       return pluginDirectory;
     }
 
+    Directory createPlugin({
+      @required String name,
+      @required Map<String, _PluginPlatformInfo> platforms,
+      List<String> dependencies = const <String>[],
+    }) {
+      assert(name != null);
+      assert(dependencies != null);
+
+      final Iterable<String> platformSections = platforms.entries.map((MapEntry<String, _PluginPlatformInfo> entry) => '''
+      ${entry.key}:
+${entry.value.indentedPubspecSection}
+''');
+      final Directory pluginDirectory = fs.systemTempDirectory.createTempSync('flutter_plugin.');
+      pluginDirectory
+        .childFile('pubspec.yaml')
+        .writeAsStringSync('''
+name: $name
+flutter:
+  plugin:
+    platforms:
+${platformSections.join('\n')}
+
+dependencies:
+''');
+      for (final String dependency in dependencies) {
+        pluginDirectory
+          .childFile('pubspec.yaml')
+          .writeAsStringSync('  $dependency:\n', mode: FileMode.append);
+      }
+      flutterProject.directory
+        .childFile('.packages')
+        .writeAsStringSync(
+          '$name:${pluginDirectory.childDirectory('lib').uri.toString()}\n',
+          mode: FileMode.append,
+        );
+      return pluginDirectory;
+    }
+
     // Creates the files that would indicate that pod install has run for the
     // given project.
     void simulatePodInstallRun(XcodeBasedProject project) {
@@ -420,9 +498,9 @@
       testUsingContext(
         'Refreshing the plugin list modifies .flutter-plugins '
         'and .flutter-plugins-dependencies when there are plugins', () async {
-        final Directory pluginA = createPluginWithDependencies(name: 'plugin-a', dependencies: const <String>['plugin-b', 'plugin-c', 'random-package']);
-        final Directory pluginB = createPluginWithDependencies(name: 'plugin-b', dependencies: const <String>['plugin-c']);
-        final Directory pluginC = createPluginWithDependencies(name: 'plugin-c', dependencies: const <String>[]);
+        final Directory pluginA = createLegacyPluginWithDependencies(name: 'plugin-a', dependencies: const <String>['plugin-b', 'plugin-c', 'random-package']);
+        final Directory pluginB = createLegacyPluginWithDependencies(name: 'plugin-b', dependencies: const <String>['plugin-c']);
+        final Directory pluginC = createLegacyPluginWithDependencies(name: 'plugin-c', dependencies: const <String>[]);
         iosProject.testExists = true;
 
         final DateTime dateCreated = DateTime(1970);
@@ -449,22 +527,25 @@
           <String, dynamic> {
             'name': 'plugin-a',
             'path': '${pluginA.path}/',
+            'native_build': true,
             'dependencies': <String>[
               'plugin-b',
               'plugin-c'
-            ]
+            ],
           },
           <String, dynamic> {
             'name': 'plugin-b',
             'path': '${pluginB.path}/',
+            'native_build': true,
             'dependencies': <String>[
               'plugin-c'
-            ]
+            ],
           },
           <String, dynamic> {
             'name': 'plugin-c',
             'path': '${pluginC.path}/',
-            'dependencies': <String>[]
+            'native_build': true,
+            'dependencies': <String>[],
           },
         ];
         expect(plugins['ios'], expectedPlugins);
@@ -514,6 +595,51 @@
         FlutterVersion: () => flutterVersion
       });
 
+      testUsingContext(
+        '.flutter-plugins-dependencies indicates native build inclusion', () async {
+        createPlugin(
+          name: 'plugin-a',
+          platforms: const <String, _PluginPlatformInfo>{
+            // Native-only; should include native build.
+            'android': _PluginPlatformInfo(pluginClass: 'Foo', androidPackage: 'bar.foo'),
+            // Hybrid native and Dart; should include native build.
+            'ios': _PluginPlatformInfo(pluginClass: 'Foo', dartPluginClass: 'Bar'),
+            // Web; should not have the native build key at all since it doesn't apply.
+            'web': _PluginPlatformInfo(pluginClass: 'Foo', fileName: 'lib/foo.dart'),
+            // Dart-only; should not include native build.
+            'windows': _PluginPlatformInfo(dartPluginClass: 'Foo'),
+          });
+        iosProject.testExists = true;
+
+        final DateTime dateCreated = DateTime(1970);
+        systemClock.currentTime = dateCreated;
+
+        await refreshPluginsList(flutterProject);
+
+        expect(flutterProject.flutterPluginsDependenciesFile.existsSync(), true);
+        final String pluginsString = flutterProject.flutterPluginsDependenciesFile.readAsStringSync();
+        final Map<String, dynamic> jsonContent = json.decode(pluginsString) as  Map<String, dynamic>;
+        final Map<String, dynamic> plugins = jsonContent['plugins'] as Map<String, dynamic>;
+
+        // Extracts the native_build key (if any) from the first plugin for the
+        // given platform.
+        bool getNativeBuildValue(String platform) {
+          final List<Map<String, dynamic>> platformPlugins = (plugins[platform]
+            as List<dynamic>).cast<Map<String, dynamic>>();
+          expect(platformPlugins.length, 1);
+          return platformPlugins[0]['native_build'] as bool;
+        }
+        expect(getNativeBuildValue('android'), true);
+        expect(getNativeBuildValue('ios'), true);
+        expect(getNativeBuildValue('web'), null);
+        expect(getNativeBuildValue('windows'), false);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        SystemClock: () => systemClock,
+        FlutterVersion: () => flutterVersion
+      });
+
       testUsingContext('Changes to the plugin list invalidates the Cocoapod lockfiles', () async {
         simulatePodInstallRun(iosProject);
         simulatePodInstallRun(macosProject);