diff --git a/dev/integration_tests/ios_app_with_extensions/ios/Runner.xcodeproj/project.pbxproj b/dev/integration_tests/ios_app_with_extensions/ios/Runner.xcodeproj/project.pbxproj index f3e42206ae..4c623d7178 100644 --- a/dev/integration_tests/ios_app_with_extensions/ios/Runner.xcodeproj/project.pbxproj +++ b/dev/integration_tests/ios_app_with_extensions/ios/Runner.xcodeproj/project.pbxproj @@ -727,6 +727,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; IBSC_MODULE = watch_Extension; INFOPLIST_FILE = watch/Info.plist; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = io.flutter.extensionTest; MARKETING_VERSION = 1.0.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -757,6 +758,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; IBSC_MODULE = watch_Extension; INFOPLIST_FILE = watch/Info.plist; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = io.flutter.extensionTest; MARKETING_VERSION = 1.0.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; @@ -785,6 +787,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; IBSC_MODULE = watch_Extension; INFOPLIST_FILE = watch/Info.plist; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = io.flutter.extensionTest; MARKETING_VERSION = 1.0.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; diff --git a/dev/integration_tests/ios_app_with_extensions/ios/watch/Info.plist b/dev/integration_tests/ios_app_with_extensions/ios/watch/Info.plist index 62d335f672..a53975e00f 100644 --- a/dev/integration_tests/ios_app_with_extensions/ios/watch/Info.plist +++ b/dev/integration_tests/ios_app_with_extensions/ios/watch/Info.plist @@ -25,8 +25,6 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - WKCompanionAppBundleIdentifier - $(APP_BUNDLE_IDENTIFIER) WKWatchKitApp diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index c023f1809f..d4725e3f34 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -265,9 +265,10 @@ Future buildXcodeProject({ // Check if the project contains a watchOS companion app. final bool hasWatchCompanion = await app.project.containsWatchCompanion( - projectInfo.targets, - buildInfo, - deviceID, + targets: projectInfo.targets, + schemes: projectInfo.schemes, + buildInfo: buildInfo, + deviceId: deviceID, ); if (hasWatchCompanion) { // The -sdk argument has to be omitted if a watchOS companion app exists. diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index 4e6f5b6adc..afe88e8502 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -198,7 +198,11 @@ class XcodeProjectInterpreter { if (buildContext.environmentType == EnvironmentType.simulator) ...['-sdk', 'iphonesimulator'], '-destination', - if (deviceId != null) + if (buildContext.isWatch == true && buildContext.environmentType == EnvironmentType.physical) + 'generic/platform=watchOS' + else if (buildContext.isWatch == true) + 'generic/platform=watchOS Simulator' + else if (deviceId != null) 'id=$deviceId' else if (buildContext.environmentType == EnvironmentType.physical) 'generic/platform=iOS' @@ -376,12 +380,14 @@ class XcodeProjectBuildContext { this.configuration, this.environmentType = EnvironmentType.physical, this.deviceId, + this.isWatch = false, }); final String? scheme; final String? configuration; final EnvironmentType environmentType; final String? deviceId; + final bool isWatch; @override int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId); @@ -395,7 +401,8 @@ class XcodeProjectBuildContext { other.scheme == scheme && other.configuration == configuration && other.deviceId == deviceId && - other.environmentType == environmentType; + other.environmentType == environmentType && + other.isWatch == isWatch; } } diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index 1b0e328b9b..69131d0e2b 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -256,6 +256,8 @@ class IosProject extends XcodeBasedProject { BuildInfo? buildInfo, { EnvironmentType environmentType = EnvironmentType.physical, String? deviceId, + String? scheme, + bool isWatch = false, }) async { if (!existsSync()) { return null; @@ -265,9 +267,11 @@ class IosProject extends XcodeBasedProject { return null; } - final String? scheme = info.schemeFor(buildInfo); if (scheme == null) { - info.reportFlavorNotFoundAndExit(); + scheme = info.schemeFor(buildInfo); + if (scheme == null) { + info.reportFlavorNotFoundAndExit(); + } } final String? configuration = (await projectInfo())?.buildConfigurationFor( @@ -279,6 +283,7 @@ class IosProject extends XcodeBasedProject { scheme: scheme, configuration: configuration, deviceId: deviceId, + isWatch: isWatch, ); final Map? currentBuildSettings = _buildSettingsByBuildContext[buildContext]; if (currentBuildSettings == null) { @@ -327,7 +332,12 @@ class IosProject extends XcodeBasedProject { } /// Check if one the [targets] of the project is a watchOS companion app target. - Future containsWatchCompanion(List targets, BuildInfo buildInfo, String? deviceId) async { + Future containsWatchCompanion({ + required List targets, + required List schemes, + required BuildInfo buildInfo, + String? deviceId, + }) async { final String? bundleIdentifier = await productBundleIdentifier(buildInfo); // A bundle identifier is required for a companion app. if (bundleIdentifier == null) { @@ -336,8 +346,8 @@ class IosProject extends XcodeBasedProject { 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. + // In older versions of Xcode, if the target was a watchOS companion app, + // the Info.plist file of the target contained the key WKCompanionAppBundleIdentifier. if (infoFile.existsSync()) { final String? fromPlist = globals.plistParser.getStringValueFromFile(infoFile.path, 'WKCompanionAppBundleIdentifier'); if (bundleIdentifier == fromPlist) { @@ -357,6 +367,34 @@ class IosProject extends XcodeBasedProject { } } } + + // If key not found in Info.plist above, do more expensive check of build settings. + // In newer versions of Xcode, the build settings of the watchOS companion + // app's scheme should contain the key INFOPLIST_KEY_WKCompanionAppBundleIdentifier. + final bool watchIdentifierFound = xcodeProjectInfoFile.readAsStringSync().contains('WKCompanionAppBundleIdentifier'); + if (watchIdentifierFound == false) { + return false; + } + for (final String scheme in schemes) { + final Map? allBuildSettings = await buildSettingsForBuildInfo( + buildInfo, + deviceId: deviceId, + scheme: scheme, + isWatch: true, + ); + if (allBuildSettings != null) { + final String? fromBuild = allBuildSettings['INFOPLIST_KEY_WKCompanionAppBundleIdentifier']; + if (bundleIdentifier == fromBuild) { + return true; + } + if (fromBuild != null && fromBuild.contains(r'$')) { + final String substitutedVariable = substituteXcodeVariables(fromBuild, allBuildSettings); + if (substitutedVariable == bundleIdentifier) { + return true; + } + } + } + } return false; } diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index de496685fa..f4bc973506 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -395,6 +395,76 @@ void main() { ProcessManager: () => FakeProcessManager.any(), }); + testUsingContext('build settings uses watch destination if isWatch is true', () async { + platform.environment = const {}; + + fakeProcessManager.addCommands([ + kWhichSysctlCommand, + kx64CheckCommand, + FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-project', + '/', + '-destination', + 'generic/platform=watchOS', + '-showBuildSettings', + 'BUILD_DIR=${fileSystem.path.absolute('build', 'ios')}', + ], + exitCode: 1, + ), + ]); + + expect( + await xcodeProjectInterpreter.getBuildSettings( + '', + buildContext: const XcodeProjectBuildContext(isWatch: true), + ), + const {}, + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('build settings uses watch simulator destination if isWatch is true and environment type is simulator', () async { + platform.environment = const {}; + + fakeProcessManager.addCommands([ + kWhichSysctlCommand, + kx64CheckCommand, + FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-project', + '/', + '-sdk', + 'iphonesimulator', + '-destination', + 'generic/platform=watchOS Simulator', + '-showBuildSettings', + 'BUILD_DIR=${fileSystem.path.absolute('build', 'ios')}', + ], + exitCode: 1, + ), + ]); + + expect( + await xcodeProjectInterpreter.getBuildSettings( + '', + buildContext: const XcodeProjectBuildContext(environmentType: EnvironmentType.simulator, isWatch: true), + ), + const {}, + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + testWithoutContext('xcodebuild clean contains Flutter Xcode environment variables', () async { platform.environment = const { 'FLUTTER_XCODE_CODE_SIGN_STYLE': 'Manual', diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart index 27ab853701..3c4189b648 100644 --- a/packages/flutter_tools/test/general.shard/project_test.dart +++ b/packages/flutter_tools/test/general.shard/project_test.dart @@ -458,7 +458,8 @@ apply plugin: 'kotlin-android' testWithMocks('from build settings, if no plist', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); - xcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Runner'); + xcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', }; xcodeProjectInterpreter.xcodeProjectInfo = XcodeProjectInfo([], [], ['Runner'], logger); @@ -486,7 +487,8 @@ apply plugin: 'kotlin-android' testWithMocks('from build settings and plist, if default variable', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); - xcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Runner'); + xcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', }; xcodeProjectInterpreter.xcodeProjectInfo = XcodeProjectInfo([], [], ['Runner'], logger); @@ -499,7 +501,8 @@ apply plugin: 'kotlin-android' final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); project.ios.defaultHostInfoPlist.createSync(recursive: true); - xcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Runner'); + xcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', 'SUFFIX': 'suffix', }; @@ -534,7 +537,8 @@ apply plugin: 'kotlin-android' testWithMocks('handles case insensitive flavor', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); - xcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Free'); + xcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', }; xcodeProjectInterpreter.xcodeProjectInfo =XcodeProjectInfo([], [], ['Free'], logger); @@ -603,7 +607,8 @@ apply plugin: 'kotlin-android' testUsingContext('app product name xcodebuild settings', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); - mockXcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Runner'); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'FULL_PRODUCT_NAME': 'My App.app', }; mockXcodeProjectInterpreter.xcodeProjectInfo = XcodeProjectInfo([], [], ['Runner'], logger); @@ -705,7 +710,15 @@ apply plugin: 'kotlin-android' testUsingContext('cannot find bundle identifier', () async { final FlutterProject project = await someProject(); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isFalse); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isFalse, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -716,7 +729,8 @@ apply plugin: 'kotlin-android' group('with bundle identifier', () { setUp(() { - mockXcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(scheme: 'Runner'); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', }; mockXcodeProjectInterpreter.xcodeProjectInfo = XcodeProjectInfo([], [], ['Runner'], logger); @@ -724,7 +738,15 @@ apply plugin: 'kotlin-android' testUsingContext('no Info.plist in target', () async { final FlutterProject project = await someProject(); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isFalse); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isFalse, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -737,7 +759,15 @@ apply plugin: 'kotlin-android' final FlutterProject project = await someProject(); project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isFalse); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isFalse, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -751,7 +781,15 @@ apply plugin: 'kotlin-android' project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true); testPlistParser.setProperty('WKCompanionAppBundleIdentifier', 'io.flutter.someOTHERproject'); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isFalse); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isFalse, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -760,13 +798,21 @@ apply plugin: 'kotlin-android' FlutterProjectFactory: () => flutterProjectFactory, }); - testUsingContext('has watch companion', () async { + testUsingContext('has watch companion in plist', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true); testPlistParser.setProperty('WKCompanionAppBundleIdentifier', 'io.flutter.someProject'); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isTrue); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isTrue, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -775,16 +821,112 @@ apply plugin: 'kotlin-android' FlutterProjectFactory: () => flutterProjectFactory, }); - testUsingContext('has watch companion with build settings', () async { + testUsingContext('has watch companion in plist with xcode variable', () async { final FlutterProject project = await someProject(); project.ios.xcodeProject.createSync(); - mockXcodeProjectInterpreter.buildSettings = { + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext( + scheme: 'Runner', + deviceId: '123', + ); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', }; project.ios.hostAppRoot.childDirectory('WatchTarget').childFile('Info.plist').createSync(recursive: true); testPlistParser.setProperty('WKCompanionAppBundleIdentifier', r'$(PRODUCT_BUNDLE_IDENTIFIER)'); - expect(await project.ios.containsWatchCompanion(['WatchTarget'], BuildInfo.debug, '123'), isTrue); + expect( + await project.ios.containsWatchCompanion( + targets: ['WatchTarget'], + schemes: [], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isTrue, + ); + }, overrides: { + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + PlistParser: () => testPlistParser, + XcodeProjectInterpreter: () => mockXcodeProjectInterpreter, + FlutterProjectFactory: () => flutterProjectFactory, + }); + + testUsingContext('has watch companion in other scheme build settings', () async { + final FlutterProject project = await someProject(); + project.ios.xcodeProject.createSync(); + project.ios.xcodeProjectInfoFile.writeAsStringSync(''' + Build settings for action build and target "WatchTarget": + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = io.flutter.someProject +'''); + + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext( + scheme: 'Runner', + deviceId: '123', + ); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { + 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', + }; + + const XcodeProjectBuildContext watchBuildContext = XcodeProjectBuildContext( + scheme: 'WatchScheme', + deviceId: '123', + isWatch: true, + ); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[watchBuildContext] = { + 'INFOPLIST_KEY_WKCompanionAppBundleIdentifier': 'io.flutter.someProject', + }; + + expect( + await project.ios.containsWatchCompanion( + targets: ['Runner', 'WatchTarget'], + schemes: ['Runner', 'WatchScheme'], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isTrue, + ); + }, overrides: { + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + PlistParser: () => testPlistParser, + XcodeProjectInterpreter: () => mockXcodeProjectInterpreter, + FlutterProjectFactory: () => flutterProjectFactory, + }); + + testUsingContext('has watch companion in other scheme build settings with xcode variable', () async { + final FlutterProject project = await someProject(); + project.ios.xcodeProject.createSync(); + project.ios.xcodeProjectInfoFile.writeAsStringSync(r''' + Build settings for action build and target "WatchTarget": + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = $(PRODUCT_BUNDLE_IDENTIFIER) +'''); + const XcodeProjectBuildContext buildContext = XcodeProjectBuildContext( + scheme: 'Runner', + deviceId: '123' + ); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[buildContext] = { + 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', + }; + + const XcodeProjectBuildContext watchBuildContext = XcodeProjectBuildContext( + scheme: 'WatchScheme', + deviceId: '123', + isWatch: true, + ); + mockXcodeProjectInterpreter.buildSettingsByBuildContext[watchBuildContext] = { + 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', + 'INFOPLIST_KEY_WKCompanionAppBundleIdentifier': r'$(PRODUCT_BUNDLE_IDENTIFIER)', + }; + + expect( + await project.ios.containsWatchCompanion( + targets: ['Runner', 'WatchTarget'], + schemes: ['Runner', 'WatchScheme'], + buildInfo: BuildInfo.debug, + deviceId: '123', + ), + isTrue, + ); }, overrides: { FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), @@ -1075,7 +1217,7 @@ File androidPluginRegistrant(Directory parent) { } class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterpreter { - Map buildSettings = {}; + final Map> buildSettingsByBuildContext = >{}; late XcodeProjectInfo xcodeProjectInfo; @override @@ -1083,7 +1225,10 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete XcodeProjectBuildContext? buildContext, Duration timeout = const Duration(minutes: 1), }) async { - return buildSettings; + if (buildSettingsByBuildContext[buildContext] == null) { + return {}; + } + return buildSettingsByBuildContext[buildContext]!; } @override