[flutter_tools] fix build for projects with watchOS companion app (#51126)

Co-authored-by: Georg Wechslberger <wechslbe@ma.tum.de>
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index 5a34d94..4a35480 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -239,6 +239,7 @@
           targetOverride: mainPath,
           buildForDevice: true,
           activeArch: cpuArchitecture,
+          deviceID: id,
       );
       if (!buildResult.success) {
         _logger.printError('Could not build the precompiled application for the device.');
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index f52283b..5531a5e 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -87,6 +87,7 @@
   bool buildForDevice,
   DarwinArch activeArch,
   bool codesign = true,
+  String deviceID,
 }) async {
   if (!upgradePbxProjWithFlutterAssets(app.project, globals.logger)) {
     return XcodeBuildResult(success: false);
@@ -227,10 +228,28 @@
     }
   }
 
-  if (buildForDevice) {
-    buildCommands.addAll(<String>['-sdk', 'iphoneos']);
+  // Check if the project contains a watchOS companion app.
+  final bool hasWatchCompanion = await app.project.containsWatchCompanion(projectInfo.targets);
+  if (hasWatchCompanion) {
+    // The -sdk argument has to be omitted if a watchOS companion app exists.
+    // Otherwise the build will fail as WatchKit dependencies cannot be build using the iOS SDK.
+    globals.printStatus('Watch companion app found. Adjusting build settings.');
+    if (!buildForDevice && (deviceID == null || deviceID == '')) {
+      globals.printError('No simulator device ID has been set.');
+      globals.printError('A device ID is required to build an app with a watchOS companion app.');
+      globals.printError('Please run "flutter devices" to get a list of available device IDs');
+      globals.printError('and specify one using the -d, --device-id flag.');
+      return XcodeBuildResult(success: false);
+    }
+    if (!buildForDevice) {
+      buildCommands.addAll(<String>['-destination', 'id=$deviceID']);
+    }
   } else {
-    buildCommands.addAll(<String>['-sdk', 'iphonesimulator', '-arch', 'x86_64']);
+    if (buildForDevice) {
+      buildCommands.addAll(<String>['-sdk', 'iphoneos']);
+    } else {
+      buildCommands.addAll(<String>['-sdk', 'iphonesimulator', '-arch', 'x86_64']);
+    }
   }
 
   if (activeArch != null) {
@@ -374,8 +393,17 @@
       ),
     );
   } else {
+    // If the app contains a watch companion target, the sdk argument of xcodebuild has to be omitted.
+    // For some reason this leads to TARGET_BUILD_DIR always ending in 'iphoneos' even though the
+    // actual directory will end with 'iphonesimulator' for simulator builds.
+    // The value of TARGET_BUILD_DIR is adjusted to accommodate for this effect.
+    String targetBuildDir = buildSettings['TARGET_BUILD_DIR'];
+    if (hasWatchCompanion && !buildForDevice) {
+      globals.printTrace('Replacing iphoneos with iphonesimulator in TARGET_BUILD_DIR.');
+      targetBuildDir = targetBuildDir.replaceFirst('iphoneos', 'iphonesimulator');
+    }
     final String expectedOutputDirectory = globals.fs.path.join(
-      buildSettings['TARGET_BUILD_DIR'],
+      targetBuildDir,
       buildSettings['WRAPPER_NAME'],
     );
 
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index f28bde5..876823f 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -471,6 +471,7 @@
       buildInfo: buildInfo,
       targetOverride: mainPath,
       buildForDevice: false,
+      deviceID: id,
     );
     if (!buildResult.success) {
       throwToolExit('Could not build the application for the simulator.');
diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart
index 9928c96..fd7fa2e 100644
--- a/packages/flutter_tools/lib/src/project.dart
+++ b/packages/flutter_tools/lib/src/project.dart
@@ -486,6 +486,25 @@
     await _updateGeneratedXcodeConfigIfNeeded();
   }
 
+  /// Check if one the [targets] of the project is a watchOS companion app target.
+  Future<bool> containsWatchCompanion(List<String> targets) async {
+    final String bundleIdentifier = await productBundleIdentifier;
+    // A bundle identifier is required for a companion app.
+    if (bundleIdentifier == null) {
+      return false;
+    }
+    for (final String target in targets) {
+      // Create Info.plist file of the target.
+      final File infoFile = hostAppRoot.childDirectory(target).childFile('Info.plist');
+      // The Info.plist file of a target contains the key WKCompanionAppBundleIdentifier,
+      // if it is a watchOS companion app.
+      if (infoFile.existsSync() && globals.plistParser.getValueFromFile(infoFile.path, 'WKCompanionAppBundleIdentifier') == bundleIdentifier) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   Future<void> _updateGeneratedXcodeConfigIfNeeded() async {
     if (globals.cache.isOlderThanToolsStamp(generatedXcodePropertiesFile)) {
       await xcode.updateGeneratedXcodeProperties(
diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart
index b212434..738e028 100644
--- a/packages/flutter_tools/test/general.shard/project_test.dart
+++ b/packages/flutter_tools/test/general.shard/project_test.dart
@@ -579,6 +579,97 @@
       FlutterProjectFactory: () => flutterProjectFactory,
     }));
   });
+
+  group('watch companion', () {
+    MemoryFileSystem fs;
+    MockPlistUtils mockPlistUtils;
+    MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
+    FlutterProjectFactory flutterProjectFactory;
+    setUp(() {
+      fs = MemoryFileSystem.test();
+      mockPlistUtils = MockPlistUtils();
+      mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
+      flutterProjectFactory = FlutterProjectFactory(
+        fileSystem: fs,
+        logger: logger,
+      );
+    });
+
+    testUsingContext('cannot find bundle identifier', () async {
+      final FlutterProject project = await someProject();
+      expect(await project.ios.containsWatchCompanion(<String>['WatchTarget']), isFalse);
+    }, overrides: <Type, Generator>{
+      FileSystem: () => fs,
+      ProcessManager: () => FakeProcessManager.any(),
+      PlistParser: () => mockPlistUtils,
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+      FlutterProjectFactory: () => flutterProjectFactory,
+    });
+
+    group('with bundle identifier', () {
+      setUp(() {
+        when(mockXcodeProjectInterpreter.getBuildSettings(any, any)).thenAnswer(
+            (_) {
+            return Future<Map<String,String>>.value(<String, String>{
+              'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject',
+            });
+          }
+        );
+      });
+
+      testUsingContext('no Info.plist in target', () async {
+        final FlutterProject project = await someProject();
+        expect(await project.ios.containsWatchCompanion(<String>['WatchTarget']), isFalse);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        PlistParser: () => mockPlistUtils,
+        XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+        FlutterProjectFactory: () => flutterProjectFactory,
+      });
+
+      testUsingContext('Info.plist in target does not contain WKCompanionAppBundleIdentifier', () async {
+        final FlutterProject project = await someProject();
+        project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true);
+
+        expect(await project.ios.containsWatchCompanion(<String>['WatchTarget']), isFalse);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        PlistParser: () => mockPlistUtils,
+        XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+        FlutterProjectFactory: () => flutterProjectFactory,
+      });
+
+      testUsingContext('target WKCompanionAppBundleIdentifier is not project bundle identifier', () async {
+        final FlutterProject project = await someProject();
+        project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true);
+
+        when(mockPlistUtils.getValueFromFile(any, 'WKCompanionAppBundleIdentifier')).thenReturn('io.flutter.someOTHERproject');
+        expect(await project.ios.containsWatchCompanion(<String>['WatchTarget']), isFalse);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        PlistParser: () => mockPlistUtils,
+        XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+        FlutterProjectFactory: () => flutterProjectFactory,
+      });
+
+      testUsingContext('has watch companion', () async {
+        final FlutterProject project = await someProject();
+        project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true);
+        when(mockPlistUtils.getValueFromFile(any, 'WKCompanionAppBundleIdentifier')).thenReturn('io.flutter.someProject');
+
+        expect(await project.ios.containsWatchCompanion(<String>['WatchTarget']), isTrue);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        PlistParser: () => mockPlistUtils,
+        XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+        FlutterProjectFactory: () => flutterProjectFactory,
+      });
+    });
+  });
 }
 
 Future<FlutterProject> someProject() async {