diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart index 58829a2128..f71c0aebac 100644 --- a/packages/flutter_tools/lib/src/application_package.dart +++ b/packages/flutter_tools/lib/src/application_package.dart @@ -34,7 +34,7 @@ abstract class ApplicationPackage { 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 a1ce5e0f3f..5196e1bf59 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 @@ class BuildIOSCommand extends BuildSubCommand { 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 e394e7cef4..0c75ad29b4 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 @@ String getValueFromFile(String plistFilePath, String key) { // 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 @@ String getValueFromFile(String plistFilePath, String key) { try { final String value = runCheckedSync([ - '/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 c2aeaca6ef..9ae0d11b7e 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -20,7 +20,7 @@ import '../globals.dart'; 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 b1fc04de34..506726986d 100644 --- a/packages/flutter_tools/lib/src/project.dart +++ b/packages/flutter_tools/lib/src/project.dart @@ -13,6 +13,8 @@ import 'build_info.dart'; 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 @@ class FlutterProject { /// 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 @@ class IosProject { /// 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 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 dc19f23332..5ab8100548 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/context.dart'; 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 @@ void main() { }); }); + 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 testMethod()) { + testUsingContext(description, testMethod, overrides: { + 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({ + '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 @@ File androidPluginRegistrant(Directory parent) { .childDirectory('plugins') .childFile('GeneratedPluginRegistrant.java'); } + +class MockIOSWorkflow extends Mock implements IOSWorkflow {} + +class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter { + @override + bool get isInstalled => true; +}