Fix extraction of product bundle ID for iOS projects (#21252)

diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart
index 58829a2..f71c0ae 100644
--- a/packages/flutter_tools/lib/src/application_package.dart
+++ b/packages/flutter_tools/lib/src/application_package.dart
@@ -34,7 +34,7 @@
   File get packagesFile => null;
 
   @override
-  String toString() => displayName;
+  String toString() => displayName ?? id;
 }
 
 class AndroidApk extends ApplicationPackage {
diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart
index a1ce5e0..5196e1b 100644
--- a/packages/flutter_tools/lib/src/commands/build_ios.dart
+++ b/packages/flutter_tools/lib/src/commands/build_ios.dart
@@ -78,7 +78,7 @@
     final String logTarget = forSimulator ? 'simulator' : 'device';
 
     final String typeName = artifacts.getEngineType(TargetPlatform.ios, buildInfo.mode);
-    printStatus('Building ${app.toString()} for $logTarget ($typeName)...');
+    printStatus('Building $app for $logTarget ($typeName)...');
     final XcodeBuildResult result = await buildXcodeProject(
       app: app,
       buildInfo: buildInfo,
diff --git a/packages/flutter_tools/lib/src/ios/plist_utils.dart b/packages/flutter_tools/lib/src/ios/plist_utils.dart
index e394e7c..0c75ad2 100644
--- a/packages/flutter_tools/lib/src/ios/plist_utils.dart
+++ b/packages/flutter_tools/lib/src/ios/plist_utils.dart
@@ -16,7 +16,9 @@
   // 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;
 
@@ -24,7 +26,7 @@
 
   try {
     final String value = runCheckedSync(<String>[
-      '/usr/bin/defaults', 'read', normalizedPlistPath, key
+      executable, 'read', normalizedPlistPath, key
     ]);
     return value.isEmpty ? null : value;
   } catch (error) {
diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
index c2aeaca..9ae0d11 100644
--- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart
+++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart
@@ -20,7 +20,7 @@
 import '../project.dart';
 
 final RegExp _settingExpr = new RegExp(r'(\w+)\s*=\s*(.*)$');
-final RegExp _varExpr = new RegExp(r'\$\((.*)\)');
+final RegExp _varExpr = new RegExp(r'\$\(([^)]*)\)');
 
 String flutterFrameworkDir(BuildMode mode) {
   return fs.path.normalize(fs.path.dirname(artifacts.getArtifactPath(Artifact.flutterFramework, TargetPlatform.ios, mode)));
diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart
index b1fc04d..5067269 100644
--- a/packages/flutter_tools/lib/src/project.dart
+++ b/packages/flutter_tools/lib/src/project.dart
@@ -13,6 +13,8 @@
 import 'bundle.dart' as bundle;
 import 'cache.dart';
 import 'flutter_manifest.dart';
+import 'ios/ios_workflow.dart';
+import 'ios/plist_utils.dart' as plist;
 import 'ios/xcodeproj.dart' as xcode;
 import 'plugins.dart';
 import 'template.dart';
@@ -147,6 +149,7 @@
 /// Flutter applications and the `.ios/` sub-folder of Flutter modules.
 class IosProject {
   static final RegExp _productBundleIdPattern = new RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER\s*=\s*(.*);\s*$');
+  static const String _productBundleIdVariable = r'$(PRODUCT_BUNDLE_IDENTIFIER)';
   static const String _hostAppBundleName = 'Runner';
 
   IosProject._(this.parent);
@@ -174,22 +177,47 @@
   /// The 'Manifest.lock'.
   File get podManifestLock => directory.childDirectory('Pods').childFile('Manifest.lock');
 
+  /// The 'Info.plist' file of the host app.
+  File get hostInfoPlist => directory.childDirectory(_hostAppBundleName).childFile('Info.plist');
+
   /// '.xcodeproj' folder of the host app.
   Directory get xcodeProject => directory.childDirectory('$_hostAppBundleName.xcodeproj');
 
   /// The '.pbxproj' file of the host app.
   File get xcodeProjectInfoFile => xcodeProject.childFile('project.pbxproj');
 
-  /// The product bundle identifier of the host app.
+  /// 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 {
-    return _firstMatchInFile(xcodeProjectInfoFile, _productBundleIdPattern)?.group(1);
+    final String fromPlist = iosWorkflow.getPlistValueFromFile(
+      hostInfoPlist.path,
+      plist.kCFBundleIdentifierKey,
+    );
+    if (fromPlist != null && !fromPlist.contains('\$')) {
+      // Info.plist has no build variables in product bundle ID.
+      return fromPlist;
+    }
+    final String fromPbxproj = _firstMatchInFile(xcodeProjectInfoFile, _productBundleIdPattern)?.group(1);
+    if (fromPbxproj != null && (fromPlist == null || fromPlist == _productBundleIdVariable)) {
+      // Common case. Avoids parsing build settings.
+      return fromPbxproj;
+    }
+    if (fromPlist != null && xcode.xcodeProjectInterpreter.isInstalled) {
+      // General case: perform variable substitution using build settings.
+      return xcode.substituteXcodeVariables(fromPlist, buildSettings);
+    }
+    return null;
   }
 
   /// True, if the host app project is using Swift.
   bool get isSwift => buildSettings?.containsKey('SWIFT_VERSION');
 
   /// The build settings for the host app of this project, as a detached map.
+  ///
+  /// Returns null, if iOS tooling is unavailable.
   Map<String, String> get buildSettings {
+    if (!xcode.xcodeProjectInterpreter.isInstalled)
+      return null;
     return xcode.xcodeProjectInterpreter.getBuildSettings(xcodeProject.path, _hostAppBundleName);
   }
 
diff --git a/packages/flutter_tools/test/project_test.dart b/packages/flutter_tools/test/project_test.dart
index dc19f23..5ab8100 100644
--- a/packages/flutter_tools/test/project_test.dart
+++ b/packages/flutter_tools/test/project_test.dart
@@ -9,10 +9,13 @@
 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/xcodeproj.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
+import 'package:mockito/mockito.dart';
 
 import 'src/common.dart';
 import 'src/context.dart';
@@ -221,6 +224,55 @@
       });
     });
 
+    group('product bundle identifier', () {
+      MemoryFileSystem fs;
+      MockIOSWorkflow mockIOSWorkflow;
+      MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
+      setUp(() {
+        fs = new MemoryFileSystem();
+        mockIOSWorkflow = new MockIOSWorkflow();
+        mockXcodeProjectInterpreter = new MockXcodeProjectInterpreter();
+      });
+
+      void testWithMocks(String description, Future<Null> testMethod()) {
+        testUsingContext(description, testMethod, overrides: <Type, Generator>{
+          FileSystem: () => fs,
+          IOSWorkflow: () => mockIOSWorkflow,
+          XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+        });
+      }
+
+      testWithMocks('null, if no pbxproj or plist entries', () async {
+        final FlutterProject project = await someProject();
+        expect(project.ios.productBundleIdentifier, isNull);
+      });
+      testWithMocks('from pbxproj file, if no plist', () async {
+        final FlutterProject project = await someProject();
+        addIosWithBundleId(project.directory, 'io.flutter.someProject');
+        expect(project.ios.productBundleIdentifier, 'io.flutter.someProject');
+      });
+      testWithMocks('from plist, if no variables', () async {
+        final FlutterProject project = await someProject();
+        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('io.flutter.someProject');
+        expect(project.ios.productBundleIdentifier, 'io.flutter.someProject');
+      });
+      testWithMocks('from pbxproj and plist, if default variable', () async {
+        final FlutterProject project = await someProject();
+        addIosWithBundleId(project.directory, 'io.flutter.someProject');
+        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER)');
+        expect(project.ios.productBundleIdentifier, 'io.flutter.someProject');
+      });
+      testWithMocks('from pbxproj and plist, by substitution', () async {
+        final FlutterProject project = await someProject();
+        when(mockXcodeProjectInterpreter.getBuildSettings(any, any)).thenReturn(<String, String>{
+          'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject',
+          'SUFFIX': 'suffix',
+        });
+        when(mockIOSWorkflow.getPlistValueFromFile(any, any)).thenReturn('\$(PRODUCT_BUNDLE_IDENTIFIER).\$(SUFFIX)');
+        expect(project.ios.productBundleIdentifier, 'io.flutter.someProject.suffix');
+      });
+    });
+
     group('organization names set', () {
       testInMemory('is empty, if project not created', () async {
         final FlutterProject project = await someProject();
@@ -457,3 +509,10 @@
     .childDirectory('plugins')
     .childFile('GeneratedPluginRegistrant.java');
 }
+
+class MockIOSWorkflow extends Mock implements IOSWorkflow {}
+
+class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter {
+  @override
+  bool get isInstalled => true;
+}