Change from using `defaults` to `plutil` for Plist parsing (#38662)

We were using the `defaults` command-line utility to parse
Plist files, but it was never supported by Apple, and it
appears that in an upcoming OS release, it will be less likely
to work:

> WARNING: The defaults command will be changed in an upcoming
> major release to only operate on preferences domains. General
> plist manipulation utilities will be folded into a different
> command-line program.

Fixes https://github.com/flutter/flutter/issues/37701
diff --git a/packages/flutter_tools/lib/src/android/android_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart
index f1330dd..2fae44a 100644
--- a/packages/flutter_tools/lib/src/android/android_studio.dart
+++ b/packages/flutter_tools/lib/src/android/android_studio.dart
@@ -10,8 +10,7 @@
 import '../base/process_manager.dart';
 import '../base/version.dart';
 import '../globals.dart';
-import '../ios/ios_workflow.dart';
-import '../ios/plist_utils.dart' as plist;
+import '../ios/plist_parser.dart';
 
 AndroidStudio get androidStudio => context.get<AndroidStudio>();
 
@@ -43,34 +42,30 @@
   factory AndroidStudio.fromMacOSBundle(String bundlePath) {
     String studioPath = fs.path.join(bundlePath, 'Contents');
     String plistFile = fs.path.join(studioPath, 'Info.plist');
-    String plistValue = iosWorkflow.getPlistValueFromFile(
-      plistFile,
-      null,
-    );
-    final RegExp _pathsSelectorMatcher = RegExp(r'"idea.paths.selector" = "[^;]+"');
-    final RegExp _jetBrainsToolboxAppMatcher = RegExp(r'JetBrainsToolboxApp = "[^;]+"');
+    Map<String, dynamic> plistValues = PlistParser.instance.parseFile(plistFile);
     // As AndroidStudio managed by JetBrainsToolbox could have a wrapper pointing to the real Android Studio.
     // Check if we've found a JetBrainsToolbox wrapper and deal with it properly.
-    final String jetBrainsToolboxAppBundlePath = extractStudioPlistValueWithMatcher(plistValue, _jetBrainsToolboxAppMatcher);
+    final String jetBrainsToolboxAppBundlePath = plistValues['JetBrainsToolboxApp'];
     if (jetBrainsToolboxAppBundlePath != null) {
       studioPath = fs.path.join(jetBrainsToolboxAppBundlePath, 'Contents');
       plistFile = fs.path.join(studioPath, 'Info.plist');
-      plistValue = iosWorkflow.getPlistValueFromFile(
-        plistFile,
-        null,
-      );
+      plistValues = PlistParser.instance.parseFile(plistFile);
     }
 
-    final String versionString = iosWorkflow.getPlistValueFromFile(
-      plistFile,
-      plist.kCFBundleShortVersionStringKey,
-    );
+    final String versionString = plistValues[PlistParser.kCFBundleShortVersionStringKey];
 
     Version version;
     if (versionString != null)
       version = Version.parse(versionString);
 
-    final String pathsSelectorValue = extractStudioPlistValueWithMatcher(plistValue, _pathsSelectorMatcher);
+    String pathsSelectorValue;
+    final Map<String, dynamic> jvmOptions = plistValues['JVMOptions'];
+    if (jvmOptions != null) {
+      final Map<String, dynamic> jvmProperties = jvmOptions['Properties'];
+      if (jvmProperties != null) {
+        pathsSelectorValue = jvmProperties['idea.paths.selector'];
+      }
+    }
     final String presetPluginsPath = pathsSelectorValue == null
         ? null
         : fs.path.join(homeDirPath, 'Library', 'Application Support', '$pathsSelectorValue');
diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart
index a032cfd..9da49e9 100644
--- a/packages/flutter_tools/lib/src/application_package.dart
+++ b/packages/flutter_tools/lib/src/application_package.dart
@@ -19,8 +19,7 @@
 import 'build_info.dart';
 import 'fuchsia/application_package.dart';
 import 'globals.dart';
-import 'ios/ios_workflow.dart';
-import 'ios/plist_utils.dart' as plist;
+import 'ios/plist_parser.dart';
 import 'linux/application_package.dart';
 import 'macos/application_package.dart';
 import 'project.dart';
@@ -309,9 +308,9 @@
       printError('Invalid prebuilt iOS app. Does not contain Info.plist.');
       return null;
     }
-    final String id = iosWorkflow.getPlistValueFromFile(
+    final String id = PlistParser.instance.getValueFromFile(
       plistPath,
-      plist.kCFBundleIdentifierKey,
+      PlistParser.kCFBundleIdentifierKey,
     );
     if (id == null) {
       printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier');
diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart
index 94de99c..1048e32 100644
--- a/packages/flutter_tools/lib/src/base/file_system.dart
+++ b/packages/flutter_tools/lib/src/base/file_system.dart
@@ -157,3 +157,13 @@
   return referenceFile.existsSync()
       && referenceFile.lastModifiedSync().isAfter(entity.statSync().modified);
 }
+
+/// Exception indicating that a file that was expected to exist was not found.
+class FileNotFoundException implements IOException {
+  const FileNotFoundException(this.path);
+
+  final String path;
+
+  @override
+  String toString() => 'File not found: $path';
+}
diff --git a/packages/flutter_tools/lib/src/commands/build_aot.dart b/packages/flutter_tools/lib/src/commands/build_aot.dart
index 6718891..bbb03f2 100644
--- a/packages/flutter_tools/lib/src/commands/build_aot.dart
+++ b/packages/flutter_tools/lib/src/commands/build_aot.dart
@@ -15,7 +15,7 @@
 import '../build_info.dart';
 import '../dart/package_map.dart';
 import '../globals.dart';
-import '../ios/ios_workflow.dart';
+import '../ios/plist_parser.dart';
 import '../macos/xcode.dart';
 import '../resident_runner.dart';
 import '../runner/flutter_command.dart';
@@ -222,7 +222,7 @@
   }
   final RunResult clangResult = await xcode.clang(<String>['--version']);
   final String clangVersion = clangResult.stdout.split('\n').first;
-  final String engineClangVersion = iosWorkflow.getPlistValueFromFile(
+  final String engineClangVersion = PlistParser.instance.getValueFromFile(
     fs.path.join(flutterFrameworkPath, 'Info.plist'),
     'ClangVersion',
   );
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index fb12357..083a74a 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -24,7 +24,7 @@
 import 'globals.dart';
 import 'intellij/intellij.dart';
 import 'ios/ios_workflow.dart';
-import 'ios/plist_utils.dart';
+import 'ios/plist_parser.dart';
 import 'linux/linux_doctor.dart';
 import 'linux/linux_workflow.dart';
 import 'macos/cocoapods_validator.dart';
@@ -731,9 +731,9 @@
   String get version {
     if (_version == null) {
       final String plistFile = fs.path.join(installPath, 'Contents', 'Info.plist');
-      _version = iosWorkflow.getPlistValueFromFile(
+      _version = PlistParser.instance.getValueFromFile(
         plistFile,
-        kCFBundleShortVersionStringKey,
+        PlistParser.kCFBundleShortVersionStringKey,
       ) ?? 'unknown';
     }
     return _version;
diff --git a/packages/flutter_tools/lib/src/ios/ios_workflow.dart b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
index f21dffd..ac9630b 100644
--- a/packages/flutter_tools/lib/src/ios/ios_workflow.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
@@ -6,7 +6,6 @@
 import '../base/platform.dart';
 import '../doctor.dart';
 import '../macos/xcode.dart';
-import 'plist_utils.dart' as plist;
 
 IOSWorkflow get iosWorkflow => context.get<IOSWorkflow>();
 
@@ -27,8 +26,4 @@
 
   @override
   bool get canListEmulators => false;
-
-  String getPlistValueFromFile(String path, String key) {
-    return plist.getValueFromFile(path, key);
-  }
 }
diff --git a/packages/flutter_tools/lib/src/ios/plist_parser.dart b/packages/flutter_tools/lib/src/ios/plist_parser.dart
new file mode 100644
index 0000000..8e8beeb
--- /dev/null
+++ b/packages/flutter_tools/lib/src/ios/plist_parser.dart
@@ -0,0 +1,63 @@
+// Copyright 2016 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 '../base/context.dart';
+import '../base/file_system.dart';
+import '../base/process.dart';
+import '../convert.dart';
+import '../globals.dart';
+
+class PlistParser {
+  const PlistParser();
+
+  static const String kCFBundleIdentifierKey = 'CFBundleIdentifier';
+  static const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString';
+  static const String kCFBundleExecutable = 'CFBundleExecutable';
+
+  static PlistParser get instance => context.get<PlistParser>() ?? const PlistParser();
+
+  /// Parses the plist file located at [plistFilePath] and returns the
+  /// associated map of key/value property list pairs.
+  ///
+  /// If [plistFilePath] points to a non-existent file or a file that's not a
+  /// valid property list file, this will return an empty map.
+  ///
+  /// The [plistFilePath] argument must not be null.
+  Map<String, dynamic> parseFile(String plistFilePath) {
+    assert(plistFilePath != null);
+    const String executable = '/usr/bin/plutil';
+    if (!fs.isFileSync(executable))
+      throw const FileNotFoundException(executable);
+    if (!fs.isFileSync(plistFilePath))
+      return const <String, dynamic>{};
+
+    final String normalizedPlistPath = fs.path.absolute(plistFilePath);
+
+    try {
+      final List<String> args = <String>[
+        executable, '-convert', 'json', '-o', '-', normalizedPlistPath,
+      ];
+      final String jsonContent = runCheckedSync(args);
+      return json.decode(jsonContent);
+    } catch (error) {
+      printTrace('$error');
+      return const <String, dynamic>{};
+    }
+  }
+
+  /// Parses the Plist file located at [plistFilePath] and returns the value
+  /// that's associated with the specified [key] within the property list.
+  ///
+  /// If [plistFilePath] points to a non-existent file or a file that's not a
+  /// valid property list file, this will return null.
+  ///
+  /// If [key] is not found in the property list, this will return null.
+  ///
+  /// The [plistFilePath] and [key] arguments must not be null.
+  String getValueFromFile(String plistFilePath, String key) {
+    assert(key != null);
+    final Map<String, dynamic> parsed = parseFile(plistFilePath);
+    return parsed[key];
+  }
+}
diff --git a/packages/flutter_tools/lib/src/ios/plist_utils.dart b/packages/flutter_tools/lib/src/ios/plist_utils.dart
deleted file mode 100644
index 0a67279..0000000
--- a/packages/flutter_tools/lib/src/ios/plist_utils.dart
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright 2016 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 '../base/file_system.dart';
-import '../base/process.dart';
-
-const String kCFBundleIdentifierKey = 'CFBundleIdentifier';
-const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString';
-const String kCFBundleExecutable = 'CFBundleExecutable';
-
-// Prefer using [iosWorkflow.getPlistValueFromFile] to enable mocking.
-String getValueFromFile(String plistFilePath, String key) {
-  // TODO(chinmaygarde): For now, we only need to read from plist files on a mac
-  // host. If this changes, we will need our own Dart plist reader.
-
-  // Don't use PlistBuddy since that is not guaranteed to be installed.
-  // 'defaults' requires the path to be absolute and without the 'plist'
-  // extension.
-  const String executable = '/usr/bin/defaults';
-  if (!fs.isFileSync(executable))
-    return null;
-  if (!fs.isFileSync(plistFilePath))
-    return null;
-
-  final String normalizedPlistPath = fs.path.withoutExtension(fs.path.absolute(plistFilePath));
-
-  try {
-    final List<String> args = <String>[
-      executable, 'read', normalizedPlistPath,
-    ];
-    if (key != null && key.isNotEmpty) {
-      args.add(key);
-    }
-    final String value = runCheckedSync(args);
-    return value.isEmpty ? null : value;
-  } catch (error) {
-    return null;
-  }
-}
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 043f651..ad46f6d 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -24,7 +24,7 @@
 import '../protocol_discovery.dart';
 import 'ios_workflow.dart';
 import 'mac.dart';
-import 'plist_utils.dart';
+import 'plist_parser.dart';
 
 const String _xcrunPath = '/usr/bin/xcrun';
 const String iosSimulatorId = 'apple_ios_simulator';
@@ -379,7 +379,7 @@
       // parsing the xcodeproj or configuration files.
       // See https://github.com/flutter/flutter/issues/31037 for more information.
       final String plistPath = fs.path.join(package.simulatorBundlePath, 'Info.plist');
-      final String bundleIdentifier = iosWorkflow.getPlistValueFromFile(plistPath, kCFBundleIdentifierKey);
+      final String bundleIdentifier = PlistParser.instance.getValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey);
 
       await SimControl.instance.launch(id, bundleIdentifier, args);
     } catch (error) {
diff --git a/packages/flutter_tools/lib/src/macos/application_package.dart b/packages/flutter_tools/lib/src/macos/application_package.dart
index 278122a..e29f356 100644
--- a/packages/flutter_tools/lib/src/macos/application_package.dart
+++ b/packages/flutter_tools/lib/src/macos/application_package.dart
@@ -8,7 +8,7 @@
 import '../base/file_system.dart';
 import '../build_info.dart';
 import '../globals.dart';
-import '../ios/plist_utils.dart' as plist;
+import '../ios/plist_parser.dart';
 import '../project.dart';
 
 /// Tests whether a [FileSystemEntity] is an macOS bundle directory
@@ -65,8 +65,9 @@
       printError('Invalid prebuilt macOS app. Does not contain Info.plist.');
       return null;
     }
-    final String id = plist.getValueFromFile(plistPath, plist.kCFBundleIdentifierKey);
-    final String executableName = plist.getValueFromFile(plistPath, plist.kCFBundleExecutable);
+    final Map<String, dynamic> propertyValues = PlistParser.instance.parseFile(plistPath);
+    final String id = propertyValues[PlistParser.kCFBundleIdentifierKey];
+    final String executableName = propertyValues[PlistParser.kCFBundleExecutable];
     if (id == null) {
       printError('Invalid prebuilt macOS app. Info.plist does not contain bundle identifier');
       return null;
diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart
index ef83cfe..1e99c89 100644
--- a/packages/flutter_tools/lib/src/project.dart
+++ b/packages/flutter_tools/lib/src/project.dart
@@ -17,8 +17,7 @@
 import 'features.dart';
 import 'flutter_manifest.dart';
 import 'globals.dart';
-import 'ios/ios_workflow.dart';
-import 'ios/plist_utils.dart' as plist;
+import 'ios/plist_parser.dart';
 import 'ios/xcodeproj.dart' as xcode;
 import 'plugins.dart';
 import 'template.dart';
@@ -361,10 +360,15 @@
   /// The product bundle identifier of the host app, or null if not set or if
   /// iOS tooling needed to read it is not installed.
   String get productBundleIdentifier {
-    final String fromPlist = iosWorkflow.getPlistValueFromFile(
-      hostInfoPlist.path,
-      plist.kCFBundleIdentifierKey,
-    );
+    String fromPlist;
+    try {
+      fromPlist = PlistParser.instance.getValueFromFile(
+        hostInfoPlist.path,
+        PlistParser.kCFBundleIdentifierKey,
+      );
+    } on FileNotFoundException {
+      // iOS tooling not found; likely not running OSX; let [fromPlist] be null
+    }
     if (fromPlist != null && !fromPlist.contains('\$')) {
       // Info.plist has no build variables in product bundle ID.
       return fromPlist;
diff --git a/packages/flutter_tools/test/general.shard/android/android_studio_test.dart b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart
index 69a7a8d..cd7216d 100644
--- a/packages/flutter_tools/test/general.shard/android/android_studio_test.dart
+++ b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart
@@ -6,7 +6,7 @@
 import 'package:flutter_tools/src/android/android_studio.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:flutter_tools/src/base/platform.dart';
-import 'package:flutter_tools/src/ios/ios_workflow.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
 import 'package:mockito/mockito.dart';
 
 import '../../src/common.dart';
@@ -15,47 +15,19 @@
 const String homeLinux = '/home/me';
 const String homeMac = '/Users/me';
 
-const String macStudioInfoPlistValue =
-'''
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-  <dict>
-    <key>CFBundleGetInfoString</key>
-    <string>Android Studio 3.3, build AI-182.5107.16.33.5199772. Copyright JetBrains s.r.o., (c) 2000-2018</string>
-    <key>CFBundleShortVersionString</key>
-    <string>3.3</string>
-    <key>CFBundleVersion</key>
-    <string>AI-182.5107.16.33.5199772</string>
-    <key>JVMOptions</key>
-    <dict>
-      <key>Properties</key>
-      <dict>
-        <key>idea.platform.prefix</key>
-        <string>AndroidStudio</string>
-        <key>idea.paths.selector</key>
-        <string>AndroidStudio3.3</string>
-      </dict>
-    </dict>
-  </dict>
-</plist>
-      ''';
-const String macStudioInfoPlistDefaultsResult =
-'''
-{
-    CFBundleGetInfoString = "Android Studio 3.3, build AI-182.5107.16.33.5199772. Copyright JetBrains s.r.o., (c) 2000-2018";
-    CFBundleShortVersionString = "3.3";
-    CFBundleVersion = "AI-182.5107.16.33.5199772";
-    JVMOptions =     {
-        Properties =         {
-            "idea.paths.selector" = "AndroidStudio3.3";
-            "idea.platform.prefix" = AndroidStudio;
-        };
-    };
-}
-''';
+const Map<String, dynamic> macStudioInfoPlist = <String, dynamic>{
+  'CFBundleGetInfoString': 'Android Studio 3.3, build AI-182.5107.16.33.5199772. Copyright JetBrains s.r.o., (c) 2000-2018',
+  'CFBundleShortVersionString': '3.3',
+  'CFBundleVersion': 'AI-182.5107.16.33.5199772',
+  'JVMOptions': <String, dynamic>{
+    'Properties': <String, dynamic>{
+      'idea.paths.selector': 'AndroidStudio3.3',
+      'idea.platform.prefix': 'AndroidStudio',
+    },
+  },
+};
 
-class MockIOSWorkflow extends Mock implements IOSWorkflow {}
+class MockPlistUtils extends Mock implements PlistParser {}
 
 Platform linuxPlatform() {
   return FakePlatform.fromPlatform(const LocalPlatform())
@@ -71,11 +43,11 @@
 
 void main() {
   MemoryFileSystem fs;
-  MockIOSWorkflow iosWorkflow;
+  MockPlistUtils plistUtils;
 
   setUp(() {
     fs = MemoryFileSystem();
-    iosWorkflow = MockIOSWorkflow();
+    plistUtils = MockPlistUtils();
   });
 
   group('pluginsPath on Linux', () {
@@ -106,8 +78,7 @@
       fs.directory(studioInApplicationPlistFolder).createSync(recursive: true);
 
       final String plistFilePath = fs.path.join(studioInApplicationPlistFolder, 'Info.plist');
-      fs.file(plistFilePath).writeAsStringSync(macStudioInfoPlistValue);
-      when(iosWorkflow.getPlistValueFromFile(plistFilePath, null)).thenReturn(macStudioInfoPlistDefaultsResult);
+      when(plistUtils.parseFile(plistFilePath)).thenReturn(macStudioInfoPlist);
       final AndroidStudio studio = AndroidStudio.fromMacOSBundle(fs.directory(studioInApplicationPlistFolder)?.parent?.path);
       expect(studio, isNotNull);
       expect(studio.pluginsPath,
@@ -117,47 +88,25 @@
       // Custom home paths are not supported on macOS nor Windows yet,
       // so we force the platform to fake Linux here.
       Platform: () => macPlatform(),
-      IOSWorkflow: () => iosWorkflow,
+      PlistParser: () => plistUtils,
     });
 
     testUsingContext('extracts custom paths for Android Studio downloaded by JetBrainsToolbox on Mac', () {
       final String jetbrainsStudioInApplicationPlistFolder = fs.path.join(homeMac, 'Application', 'JetBrains Toolbox', 'Android Studio.app', 'Contents');
       fs.directory(jetbrainsStudioInApplicationPlistFolder).createSync(recursive: true);
-      const String jetbrainsInfoPlistValue =
-      '''
-<?xml version='1.0' encoding='UTF-8'?>
-<!DOCTYPE plist PUBLIC '-//Apple Computer//DTD PLIST 1.0//EN' 'http://www.apple.com/DTDs/PropertyList-1.0.dtd'>
-<plist version="1.0">
- <dict>
-  <key>CFBundleVersion</key>
-  <string>3.3</string>
-  <key>CFBundleLongVersionString</key>
-  <string>3.3</string>
-  <key>CFBundleShortVersionString</key>
-  <string>3.3</string>
-  <key>JetBrainsToolboxApp</key>
-  <string>$homeMac/Library/Application Support/JetBrains/Toolbox/apps/AndroidStudio/ch-0/183.5256920/Android Studio 3.3</string>
- </dict>
-</plist>
-      ''';
-      const String jetbrainsInfoPlistDefaultsResult =
-      '''
-{
-    CFBundleLongVersionString = "3.3";
-    CFBundleShortVersionString = "3.3";
-    CFBundleVersion = "3.3";
-    JetBrainsToolboxApp = "$homeMac/Library/Application Support/JetBrains/Toolbox/apps/AndroidStudio/ch-0/183.5256920/Android Studio 3.3.app";
-}
-''';
+      const Map<String, dynamic> jetbrainsInfoPlist = <String, dynamic>{
+        'CFBundleLongVersionString': '3.3',
+        'CFBundleShortVersionString': '3.3',
+        'CFBundleVersion': '3.3',
+        'JetBrainsToolboxApp': '$homeMac/Library/Application Support/JetBrains/Toolbox/apps/AndroidStudio/ch-0/183.5256920/Android Studio 3.3.app',
+      };
       final String jetbrainsPlistFilePath = fs.path.join(jetbrainsStudioInApplicationPlistFolder, 'Info.plist');
-      fs.file(jetbrainsPlistFilePath).writeAsStringSync(jetbrainsInfoPlistValue);
-      when(iosWorkflow.getPlistValueFromFile(jetbrainsPlistFilePath, null)).thenReturn(jetbrainsInfoPlistDefaultsResult);
+      when(plistUtils.parseFile(jetbrainsPlistFilePath)).thenReturn(jetbrainsInfoPlist);
 
       final String studioInApplicationPlistFolder = fs.path.join(fs.path.join(homeMac, 'Library', 'Application Support'), 'JetBrains', 'Toolbox', 'apps', 'AndroidStudio', 'ch-0', '183.5256920', fs.path.join('Android Studio 3.3.app', 'Contents'));
       fs.directory(studioInApplicationPlistFolder).createSync(recursive: true);
       final String studioPlistFilePath = fs.path.join(studioInApplicationPlistFolder, 'Info.plist');
-      fs.file(studioPlistFilePath).writeAsStringSync(macStudioInfoPlistValue);
-      when(iosWorkflow.getPlistValueFromFile(studioPlistFilePath, null)).thenReturn(macStudioInfoPlistDefaultsResult);
+      when(plistUtils.parseFile(studioPlistFilePath)).thenReturn(macStudioInfoPlist);
 
       final AndroidStudio studio = AndroidStudio.fromMacOSBundle(fs.directory(jetbrainsStudioInApplicationPlistFolder)?.parent?.path);
       expect(studio, isNotNull);
@@ -168,7 +117,7 @@
       // Custom home paths are not supported on macOS nor Windows yet,
       // so we force the platform to fake Linux here.
       Platform: () => macPlatform(),
-      IOSWorkflow: () => iosWorkflow,
+      PlistParser: () => plistUtils,
     });
 
   });
diff --git a/packages/flutter_tools/test/general.shard/application_package_test.dart b/packages/flutter_tools/test/general.shard/application_package_test.dart
index a531335..31ce93f 100644
--- a/packages/flutter_tools/test/general.shard/application_package_test.dart
+++ b/packages/flutter_tools/test/general.shard/application_package_test.dart
@@ -7,20 +7,19 @@
 
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
-import 'package:flutter_tools/src/base/platform.dart';
-import 'package:flutter_tools/src/build_info.dart';
-import 'package:flutter_tools/src/cache.dart';
-import 'package:flutter_tools/src/fuchsia/application_package.dart';
-import 'package:flutter_tools/src/project.dart';
-import 'package:mockito/mockito.dart';
-
-import 'package:flutter_tools/src/application_package.dart';
 import 'package:flutter_tools/src/android/android_sdk.dart';
+import 'package:flutter_tools/src/application_package.dart';
 import 'package:flutter_tools/src/base/context.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/os.dart';
-import 'package:flutter_tools/src/ios/ios_workflow.dart';
+import 'package:flutter_tools/src/base/platform.dart';
+import 'package:flutter_tools/src/build_info.dart';
+import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/fuchsia/application_package.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
+import 'package:flutter_tools/src/project.dart';
+import 'package:mockito/mockito.dart';
 import 'package:process/process.dart';
 
 import '../src/common.dart';
@@ -190,7 +189,7 @@
   group('PrebuiltIOSApp', () {
     final Map<Type, Generator> overrides = <Type, Generator>{
       FileSystem: () => MemoryFileSystem(),
-      IOSWorkflow: () => MockIosWorkFlow(),
+      PlistParser: () => MockPlistUtils(),
       Platform: _kNoColorTerminalPlatform,
       OperatingSystemUtils: () => MockOperatingSystemUtils(),
     };
@@ -587,9 +586,9 @@
 ''';
 
 
-class MockIosWorkFlow extends Mock implements IOSWorkflow {
+class MockPlistUtils extends Mock implements PlistParser {
   @override
-  String getPlistValueFromFile(String path, String key) {
+  String getValueFromFile(String path, String key) {
     final File file = fs.file(path);
     if (!file.existsSync()) {
       return null;
diff --git a/packages/flutter_tools/test/general.shard/commands/build_aot_test.dart b/packages/flutter_tools/test/general.shard/commands/build_aot_test.dart
index f55aa6f..f12e63b 100644
--- a/packages/flutter_tools/test/general.shard/commands/build_aot_test.dart
+++ b/packages/flutter_tools/test/general.shard/commands/build_aot_test.dart
@@ -8,7 +8,7 @@
 import 'package:flutter_tools/src/base/process.dart';
 import 'package:flutter_tools/src/commands/build_aot.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
-import 'package:flutter_tools/src/ios/ios_workflow.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
 import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:mockito/mockito.dart';
 import 'package:process/process.dart';
@@ -22,14 +22,14 @@
   MemoryFileSystem memoryFileSystem;
   MockProcessManager mockProcessManager;
   BufferLogger bufferLogger;
-  MockIOSWorkflow mockIOSWorkflow;
+  MockPlistUtils mockPlistUtils;
 
   setUp(() {
     mockXcode = MockXcode();
     memoryFileSystem = MemoryFileSystem(style: FileSystemStyle.posix);
     mockProcessManager = MockProcessManager();
     bufferLogger = BufferLogger();
-    mockIOSWorkflow = MockIOSWorkflow();
+    mockPlistUtils = MockPlistUtils();
   });
 
   testUsingContext('build aot validates building with bitcode requires a local engine', () async {
@@ -87,7 +87,7 @@
     );
     when(mockXcode.otool(any)).thenAnswer((_) => Future<RunResult>.value(otoolResult));
     when(mockXcode.clang(any)).thenAnswer((_) => Future<RunResult>.value(clangResult));
-    when(mockIOSWorkflow.getPlistValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
+    when(mockPlistUtils.getValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
 
     await expectToolExitLater(
       validateBitcode(),
@@ -102,7 +102,7 @@
     ProcessManager: () => mockProcessManager,
     Xcode: () => mockXcode,
     Logger: () => bufferLogger,
-    IOSWorkflow: () => mockIOSWorkflow,
+    PlistParser: () => mockPlistUtils,
   });
 
   testUsingContext('build aot validates and succeeds - same version of Xcode', () async {
@@ -121,7 +121,7 @@
     );
     when(mockXcode.otool(any)).thenAnswer((_) => Future<RunResult>.value(otoolResult));
     when(mockXcode.clang(any)).thenAnswer((_) => Future<RunResult>.value(clangResult));
-    when(mockIOSWorkflow.getPlistValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
+    when(mockPlistUtils.getValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
 
     await validateBitcode();
 
@@ -132,7 +132,7 @@
     ProcessManager: () => mockProcessManager,
     Xcode: () => mockXcode,
     Logger: () => bufferLogger,
-    IOSWorkflow: () => mockIOSWorkflow,
+    PlistParser: () => mockPlistUtils,
   });
 
   testUsingContext('build aot validates and succeeds when user has newer version of Xcode', () async {
@@ -151,7 +151,7 @@
     );
     when(mockXcode.otool(any)).thenAnswer((_) => Future<RunResult>.value(otoolResult));
     when(mockXcode.clang(any)).thenAnswer((_) => Future<RunResult>.value(clangResult));
-    when(mockIOSWorkflow.getPlistValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
+    when(mockPlistUtils.getValueFromFile(infoPlist.path, 'ClangVersion')).thenReturn('Apple LLVM version 10.0.1 (clang-1234.1.12.1)');
 
     await validateBitcode();
 
@@ -162,9 +162,9 @@
     ProcessManager: () => mockProcessManager,
     Xcode: () => mockXcode,
     Logger: () => bufferLogger,
-    IOSWorkflow: () => mockIOSWorkflow,
+    PlistParser: () => mockPlistUtils,
   });
 }
 
 class MockXcode extends Mock implements Xcode {}
-class MockIOSWorkflow extends Mock implements IOSWorkflow {}
+class MockPlistUtils extends Mock implements PlistParser {}
diff --git a/packages/flutter_tools/test/general.shard/commands/daemon_test.dart b/packages/flutter_tools/test/general.shard/commands/daemon_test.dart
index 54aebdb..d00242d 100644
--- a/packages/flutter_tools/test/general.shard/commands/daemon_test.dart
+++ b/packages/flutter_tools/test/general.shard/commands/daemon_test.dart
@@ -316,7 +316,7 @@
 }
 
 class MockIOSWorkflow extends IOSWorkflow {
-  MockIOSWorkflow({ this.canListDevices =true });
+  MockIOSWorkflow({ this.canListDevices = true });
 
   @override
   final bool canListDevices;
diff --git a/packages/flutter_tools/test/general.shard/ios/plist_parser_test.dart b/packages/flutter_tools/test/general.shard/ios/plist_parser_test.dart
new file mode 100644
index 0000000..5f1ef9c
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/ios/plist_parser_test.dart
@@ -0,0 +1,114 @@
+// Copyright 2019 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:convert';
+import 'dart:io';
+
+import 'package:file/file.dart';
+import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
+import 'package:process/process.dart';
+
+import '../../src/common.dart';
+import '../../src/context.dart';
+
+const String base64PlistXml =
+    'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0I'
+    'FBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS'
+    '5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI+CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo'
+    '8ZGljdD4KICA8a2V5PkNGQnVuZGxlRXhlY3V0YWJsZTwva2V5PgogIDxzdHJpbmc+QXBwPC9z'
+    'dHJpbmc+CiAgPGtleT5DRkJ1bmRsZUlkZW50aWZpZXI8L2tleT4KICA8c3RyaW5nPmlvLmZsd'
+    'XR0ZXIuZmx1dHRlci5hcHA8L3N0cmluZz4KPC9kaWN0Pgo8L3BsaXN0Pgo=';
+
+const String base64PlistBinary =
+    'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QEkNGQnVuZGxlSWRlbnRpZmllc'
+    'lNBcHBfEBZpby5mbHV0dGVyLmZsdXR0ZXIuYXBwCA0iNzsAAAAAAAABAQAAAAAAAAAFAAAAAA'
+    'AAAAAAAAAAAAAAVA==';
+
+const String base64PlistJson =
+    'eyJDRkJ1bmRsZUV4ZWN1dGFibGUiOiJBcHAiLCJDRkJ1bmRsZUlkZW50aWZpZXIiOiJpby5mb'
+    'HV0dGVyLmZsdXR0ZXIuYXBwIn0=';
+
+void main() {
+  group('PlistUtils', () {
+    // The tests herein explicitly don't use `MemoryFileSystem` or a mocked
+    // `ProcessManager` because doing so wouldn't actually test what we want to
+    // test, which is that the underlying tool we're using to parse Plist files
+    // works with the way we're calling it.
+    final Map<Type, Generator> overrides = <Type, Generator>{
+      FileSystem: () => const LocalFileSystemBlockingSetCurrentDirectory(),
+      ProcessManager: () => const LocalProcessManager(),
+    };
+
+    const PlistParser parser = PlistParser();
+
+    if (Platform.isMacOS) {
+      group('getValueFromFile', () {
+        File file;
+
+        setUp(() {
+          file = fs.file('foo.plist')..createSync();
+        });
+
+        tearDown(() {
+          file.deleteSync();
+        });
+
+        testUsingContext('works with xml file', () async {
+          file.writeAsBytesSync(base64.decode(base64PlistXml));
+          expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(testLogger.statusText, isEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+
+        testUsingContext('works with binary file', () async {
+          file.writeAsBytesSync(base64.decode(base64PlistBinary));
+          expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(testLogger.statusText, isEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+
+        testUsingContext('works with json file', () async {
+          file.writeAsBytesSync(base64.decode(base64PlistJson));
+          expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
+          expect(testLogger.statusText, isEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+
+        testUsingContext('returns null for non-existent plist file', () async {
+          expect(parser.getValueFromFile('missing.plist', 'CFBundleIdentifier'), null);
+          expect(testLogger.statusText, isEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+
+        testUsingContext('returns null for non-existent key within plist', () async {
+          file.writeAsBytesSync(base64.decode(base64PlistXml));
+          expect(parser.getValueFromFile(file.path, 'BadKey'), null);
+          expect(parser.getValueFromFile(file.absolute.path, 'BadKey'), null);
+          expect(testLogger.statusText, isEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+
+        testUsingContext('returns null for malformed plist file', () async {
+          file.writeAsBytesSync(const <int>[1, 2, 3, 4, 5, 6]);
+          expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), null);
+          expect(testLogger.statusText, isNotEmpty);
+          expect(testLogger.errorText, isEmpty);
+        }, overrides: overrides);
+      });
+    } else {
+      testUsingContext('throws when /usr/bin/plutil is not found', () async {
+        expect(
+          () => parser.getValueFromFile('irrelevant.plist', 'ununsed'),
+          throwsA(isA<FileNotFoundException>()),
+        );
+        expect(testLogger.statusText, isEmpty);
+        expect(testLogger.errorText, isEmpty);
+      }, overrides: overrides);
+    }
+  });
+}
diff --git a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart
index ce29a9a..1ef553a 100644
--- a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart
@@ -11,8 +11,8 @@
 import 'package:flutter_tools/src/device.dart';
 import 'package:flutter_tools/src/application_package.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
-import 'package:flutter_tools/src/ios/ios_workflow.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
 import 'package:flutter_tools/src/ios/simulators.dart';
 import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:flutter_tools/src/project.dart';
@@ -30,7 +30,7 @@
 class MockProcessManager extends Mock implements ProcessManager {}
 class MockXcode extends Mock implements Xcode {}
 class MockSimControl extends Mock implements SimControl {}
-class MockIOSWorkflow extends Mock implements IOSWorkflow {}
+class MockPlistUtils extends Mock implements PlistParser {}
 
 void main() {
   FakePlatform osx;
@@ -455,7 +455,7 @@
 
     testUsingContext("startApp uses compiled app's Info.plist to find CFBundleIdentifier", () async {
         final IOSSimulator device = IOSSimulator('x', name: 'iPhone SE', simulatorCategory: 'iOS 11.2');
-        when(iosWorkflow.getPlistValueFromFile(any, any)).thenReturn('correct');
+        when(PlistParser.instance.getValueFromFile(any, any)).thenReturn('correct');
 
         final Directory mockDir = fs.currentDirectory;
         final IOSApp package = PrebuiltIOSApp(projectBundleId: 'incorrect', bundleName: 'name', bundleDir: mockDir);
@@ -468,7 +468,7 @@
       },
       overrides: <Type, Generator>{
         SimControl: () => simControl,
-        IOSWorkflow: () => MockIOSWorkflow()
+        PlistParser: () => MockPlistUtils(),
       },
     );
   });
diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart
index f5b26d1..d16d495 100644
--- a/packages/flutter_tools/test/general.shard/project_test.dart
+++ b/packages/flutter_tools/test/general.shard/project_test.dart
@@ -12,7 +12,7 @@
 import 'package:flutter_tools/src/base/platform.dart';
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/flutter_manifest.dart';
-import 'package:flutter_tools/src/ios/ios_workflow.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
 import 'package:flutter_tools/src/ios/xcodeproj.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:meta/meta.dart';
@@ -278,18 +278,18 @@
 
     group('product bundle identifier', () {
       MemoryFileSystem fs;
-      MockIOSWorkflow mockIOSWorkflow;
+      MockPlistUtils mockPlistUtils;
       MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
       setUp(() {
         fs = MemoryFileSystem();
-        mockIOSWorkflow = MockIOSWorkflow();
+        mockPlistUtils = MockPlistUtils();
         mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
       });
 
       void testWithMocks(String description, Future<void> testMethod()) {
         testUsingContext(description, testMethod, overrides: <Type, Generator>{
           FileSystem: () => fs,
-          IOSWorkflow: () => mockIOSWorkflow,
+          PlistParser: () => mockPlistUtils,
           XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
         });
       }
@@ -307,7 +307,7 @@
       });
       testWithMocks('from plist, if no variables', () async {
         final FlutterProject project = await someProject();
-        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('io.flutter.someProject');
+        when(mockPlistUtils.getValueFromFile(any, any)).thenReturn('io.flutter.someProject');
         expect(project.ios.productBundleIdentifier, 'io.flutter.someProject');
       });
       testWithMocks('from pbxproj and plist, if default variable', () async {
@@ -315,7 +315,7 @@
         addIosProjectFile(project.directory, projectFileContent: () {
           return projectFileWithBundleId('io.flutter.someProject');
         });
-        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER)');
+        when(mockPlistUtils.getValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER)');
         expect(project.ios.productBundleIdentifier, 'io.flutter.someProject');
       });
       testWithMocks('from pbxproj and plist, by substitution', () async {
@@ -324,7 +324,7 @@
           'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject',
           'SUFFIX': 'suffix',
         });
-        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER).\$(SUFFIX)');
+        when(mockPlistUtils.getValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER).\$(SUFFIX)');
         expect(project.ios.productBundleIdentifier, 'io.flutter.someProject.suffix');
       });
       testWithMocks('empty surrounded by quotes', () async {
@@ -636,7 +636,7 @@
     .childFile('GeneratedPluginRegistrant.java');
 }
 
-class MockIOSWorkflow extends Mock implements IOSWorkflow {}
+class MockPlistUtils extends Mock implements PlistParser {}
 
 class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter {
   @override
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index 996c210..d5a15a8 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -18,6 +18,7 @@
 import 'package:flutter_tools/src/context_runner.dart';
 import 'package:flutter_tools/src/device.dart';
 import 'package:flutter_tools/src/doctor.dart';
+import 'package:flutter_tools/src/ios/plist_parser.dart';
 import 'package:flutter_tools/src/ios/simulators.dart';
 import 'package:flutter_tools/src/ios/xcodeproj.dart';
 import 'package:flutter_tools/src/project.dart';
@@ -86,8 +87,9 @@
           SimControl: () => MockSimControl(),
           Usage: () => FakeUsage(),
           XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(),
-          FileSystem: () => LocalFileSystemBlockingSetCurrentDirectory(),
+          FileSystem: () => const LocalFileSystemBlockingSetCurrentDirectory(),
           TimeoutConfiguration: () => const TimeoutConfiguration(),
+          PlistParser: () => FakePlistParser(),
         },
         body: () {
           final String flutterRoot = getFlutterRoot();
@@ -356,7 +358,17 @@
 
 class MockHttpClient extends Mock implements HttpClient {}
 
+class FakePlistParser implements PlistParser {
+  @override
+  Map<String, dynamic> parseFile(String plistFilePath) => const <String, dynamic>{};
+
+  @override
+  String getValueFromFile(String plistFilePath, String key) => null;
+}
+
 class LocalFileSystemBlockingSetCurrentDirectory extends LocalFileSystem {
+  const LocalFileSystemBlockingSetCurrentDirectory();
+
   @override
   set currentDirectory(dynamic value) {
     throw 'fs.currentDirectory should not be set on the local file system during '