Allow `--use-application-binary` using app-bundles on ios (#17691)

This makes it easier to run ios add2app apps with Flutter run.
diff --git a/packages/flutter_tools/lib/src/android/android_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart
index 1c2d2ed..46119f2 100644
--- a/packages/flutter_tools/lib/src/android/android_studio.dart
+++ b/packages/flutter_tools/lib/src/android/android_studio.dart
@@ -10,7 +10,8 @@
 import '../base/process_manager.dart';
 import '../base/version.dart';
 import '../globals.dart';
-import '../ios/plist_utils.dart';
+import '../ios/ios_workflow.dart';
+import '../ios/plist_utils.dart' as plist;
 
 AndroidStudio get androidStudio => context[AndroidStudio];
 
@@ -46,8 +47,11 @@
   factory AndroidStudio.fromMacOSBundle(String bundlePath) {
     final String studioPath = fs.path.join(bundlePath, 'Contents');
     final String plistFile = fs.path.join(studioPath, 'Info.plist');
-    final String versionString =
-        getValueFromFile(plistFile, kCFBundleShortVersionStringKey);
+    final String versionString = iosWorkflow.getPlistValueFromFile(
+      plistFile,
+      plist.kCFBundleShortVersionStringKey,
+    );
+
     Version version;
     if (versionString != null)
       version = new Version.parse(versionString);
diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart
index 6bc63d2..b568d78 100644
--- a/packages/flutter_tools/lib/src/application_package.dart
+++ b/packages/flutter_tools/lib/src/application_package.dart
@@ -15,6 +15,7 @@
 import 'base/process.dart';
 import 'build_info.dart';
 import 'globals.dart';
+import 'ios/ios_workflow.dart';
 import 'ios/plist_utils.dart' as plist;
 import 'ios/xcodeproj.dart';
 import 'tester/flutter_tester.dart';
@@ -145,29 +146,61 @@
 abstract class IOSApp extends ApplicationPackage {
   IOSApp({@required String projectBundleId}) : super(id: projectBundleId);
 
-  /// Creates a new IOSApp from an existing IPA.
-  factory IOSApp.fromIpa(String applicationBinary) {
+  /// Creates a new IOSApp from an existing app bundle or IPA.
+  factory IOSApp.fromPrebuiltApp(String applicationBinary) {
+    final FileSystemEntityType entityType = fs.typeSync(applicationBinary);
+    if (entityType == FileSystemEntityType.notFound) {
+      printError(
+          'File "$applicationBinary" does not exist. Use an app bundle or an ipa.');
+      return null;
+    }
     Directory bundleDir;
-    try {
-      final Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_app_');
+    if (entityType == FileSystemEntityType.directory) {
+      final Directory directory = fs.directory(applicationBinary);
+      if (!_isBundleDirectory(directory)) {
+        printError('Folder "$applicationBinary" is not an app bundle.');
+        return null;
+      }
+      bundleDir = fs.directory(applicationBinary);
+    } else {
+      // Try to unpack as an ipa.
+      final Directory tempDir = fs.systemTempDirectory.createTempSync(
+          'flutter_app_');
       addShutdownHook(() async {
         await tempDir.delete(recursive: true);
       }, ShutdownStage.STILL_RECORDING);
       os.unzip(fs.file(applicationBinary), tempDir);
-      final Directory payloadDir = fs.directory(fs.path.join(tempDir.path, 'Payload'));
-      bundleDir = payloadDir.listSync().singleWhere(_isBundleDirectory);
-    } on StateError catch (e, stackTrace) {
-      printError('Invalid prebuilt iOS binary: ${e.toString()}', stackTrace: stackTrace);
+      final Directory payloadDir = fs.directory(
+        fs.path.join(tempDir.path, 'Payload'),
+      );
+      if (!payloadDir.existsSync()) {
+        printError(
+            'Invalid prebuilt iOS ipa. Does not contain a "Payload" directory.');
+        return null;
+      }
+      try {
+        bundleDir = payloadDir.listSync().singleWhere(_isBundleDirectory);
+      } on StateError {
+        printError(
+            'Invalid prebuilt iOS ipa. Does not contain a single app bundle.');
+        return null;
+      }
+    }
+    final String plistPath = fs.path.join(bundleDir.path, 'Info.plist');
+    if (!fs.file(plistPath).existsSync()) {
+      printError('Invalid prebuilt iOS app. Does not contain Info.plist.');
+      return null;
+    }
+    final String id = iosWorkflow.getPlistValueFromFile(
+      plistPath,
+      plist.kCFBundleIdentifierKey,
+    );
+    if (id == null) {
+      printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier');
       return null;
     }
 
-    final String plistPath = fs.path.join(bundleDir.path, 'Info.plist');
-    final String id = plist.getValueFromFile(plistPath, plist.kCFBundleIdentifierKey);
-    if (id == null)
-      return null;
-
     return new PrebuiltIOSApp(
-      ipaPath: applicationBinary,
       bundleDir: bundleDir,
       bundleName: fs.path.basename(bundleDir.path),
       projectBundleId: id,
@@ -179,7 +212,10 @@
       return null;
 
     final String plistPath = fs.path.join('ios', 'Runner', 'Info.plist');
-    String id = plist.getValueFromFile(plistPath, plist.kCFBundleIdentifierKey);
+    String id = iosWorkflow.getPlistValueFromFile(
+      plistPath,
+      plist.kCFBundleIdentifierKey,
+    );
     if (id == null || !xcodeProjectInterpreter.isInstalled)
       return null;
     final String projectPath = fs.path.join('ios', 'Runner.xcodeproj');
@@ -237,12 +273,10 @@
 }
 
 class PrebuiltIOSApp extends IOSApp {
-  final String ipaPath;
   final Directory bundleDir;
   final String bundleName;
 
   PrebuiltIOSApp({
-    this.ipaPath,
     this.bundleDir,
     this.bundleName,
     @required String projectBundleId,
@@ -274,7 +308,7 @@
     case TargetPlatform.ios:
       return applicationBinary == null
           ? new IOSApp.fromCurrentDirectory()
-          : new IOSApp.fromIpa(applicationBinary);
+          : new IOSApp.fromPrebuiltApp(applicationBinary);
     case TargetPlatform.tester:
       return new FlutterTesterApp.fromCurrentDirectory();
     case TargetPlatform.darwin_x64:
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index ebae76b..3453216 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -507,7 +507,10 @@
   String get version {
     if (_version == null) {
       final String plistFile = fs.path.join(installPath, 'Contents', 'Info.plist');
-      _version = getValueFromFile(plistFile, kCFBundleShortVersionStringKey) ?? 'unknown';
+      _version = iosWorkflow.getPlistValueFromFile(
+        plistFile,
+        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 1d3b59d..3b697be 100644
--- a/packages/flutter_tools/lib/src/ios/ios_workflow.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
@@ -12,6 +12,7 @@
 import '../doctor.dart';
 import 'cocoapods.dart';
 import 'mac.dart';
+import 'plist_utils.dart' as plist;
 
 IOSWorkflow get iosWorkflow => context[IOSWorkflow];
 
@@ -33,6 +34,10 @@
   @override
   bool get canListEmulators => false;
 
+  String getPlistValueFromFile(String path, String key) {
+    return plist.getValueFromFile(path, key);
+  }
+
   Future<bool> get hasIDeviceInstaller => exitsHappyAsync(<String>['ideviceinstaller', '-h']);
 
   Future<bool> get hasIosDeploy => exitsHappyAsync(<String>['ios-deploy', '--version']);
diff --git a/packages/flutter_tools/lib/src/ios/plist_utils.dart b/packages/flutter_tools/lib/src/ios/plist_utils.dart
index c0faca6..e394e7c 100644
--- a/packages/flutter_tools/lib/src/ios/plist_utils.dart
+++ b/packages/flutter_tools/lib/src/ios/plist_utils.dart
@@ -8,6 +8,7 @@
 const String kCFBundleIdentifierKey = 'CFBundleIdentifier';
 const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString';
 
+// 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.
diff --git a/packages/flutter_tools/test/application_package_test.dart b/packages/flutter_tools/test/application_package_test.dart
index a2f4856..d4e4ded 100644
--- a/packages/flutter_tools/test/application_package_test.dart
+++ b/packages/flutter_tools/test/application_package_test.dart
@@ -2,15 +2,25 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'package:flutter_tools/src/application_package.dart';
-import 'package:test/test.dart';
+import 'dart:convert';
 
+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:test/test.dart';
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:mockito/mockito.dart';
 import 'src/context.dart';
 
 void main() {
   group('ApkManifestData', () {
     testUsingContext('parse sdk', () {
-      final ApkManifestData data = ApkManifestData.parseFromAaptBadging(_aaptData);
+      final ApkManifestData data =
+          ApkManifestData.parseFromAaptBadging(_aaptData);
       expect(data, isNotNull);
       expect(data.packageName, 'io.flutter.gallery');
       expect(data.launchableActivityName, 'io.flutter.app.FlutterActivity');
@@ -28,6 +38,118 @@
       expect(buildableIOSApp.isSwift, true);
     });
   });
+
+  group('PrebuiltIOSApp', () {
+    final Map<Type, Generator> overrides = <Type, Generator>{
+      FileSystem: () => new MemoryFileSystem(),
+      IOSWorkflow: () => new MockIosWorkFlow()
+    };
+    testUsingContext('Error on non-existing file', () {
+      final PrebuiltIOSApp iosApp =
+          new IOSApp.fromPrebuiltApp('not_existing.ipa');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(
+        logger.errorText,
+        'File "not_existing.ipa" does not exist. Use an app bundle or an ipa.\n',
+      );
+    }, overrides: overrides);
+    testUsingContext('Error on non-app-bundle folder', () {
+      fs.directory('regular_folder').createSync();
+      final PrebuiltIOSApp iosApp =
+          new IOSApp.fromPrebuiltApp('regular_folder');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(
+          logger.errorText, 'Folder "regular_folder" is not an app bundle.\n');
+    }, overrides: overrides);
+    testUsingContext('Error on no info.plist', () {
+      fs.directory('bundle.app').createSync();
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('bundle.app');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(
+        logger.errorText,
+        'Invalid prebuilt iOS app. Does not contain Info.plist.\n',
+      );
+    }, overrides: overrides);
+    testUsingContext('Error on bad info.plist', () {
+      fs.directory('bundle.app').createSync();
+      fs.file('bundle.app/Info.plist').writeAsStringSync(badPlistData);
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('bundle.app');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(
+        logger.errorText,
+        contains(
+            'Invalid prebuilt iOS app. Info.plist does not contain bundle identifier\n'),
+      );
+    }, overrides: overrides);
+    testUsingContext('Success with app bundle', () {
+      fs.directory('bundle.app').createSync();
+      fs.file('bundle.app/Info.plist').writeAsStringSync(plistData);
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('bundle.app');
+      final BufferLogger logger = context[Logger];
+      expect(logger.errorText, isEmpty);
+      expect(iosApp.bundleDir.path, 'bundle.app');
+      expect(iosApp.id, 'fooBundleId');
+      expect(iosApp.bundleName, 'bundle.app');
+    }, overrides: overrides);
+    testUsingContext('Bad ipa zip-file, no payload dir', () {
+      fs.file('app.ipa').createSync();
+      when(os.unzip(fs.file('app.ipa'), any)).thenAnswer((Invocation _) {});
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('app.ipa');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(
+        logger.errorText,
+        'Invalid prebuilt iOS ipa. Does not contain a "Payload" directory.\n',
+      );
+    }, overrides: overrides);
+    testUsingContext('Bad ipa zip-file, two app bundles', () {
+      fs.file('app.ipa').createSync();
+      when(os.unzip(any, any)).thenAnswer((Invocation invocation) {
+        final File zipFile = invocation.positionalArguments[0];
+        if (zipFile.path != 'app.ipa') {
+          return null;
+        }
+        final Directory targetDirectory = invocation.positionalArguments[1];
+        final String bundlePath1 =
+            fs.path.join(targetDirectory.path, 'Payload', 'bundle1.app');
+        final String bundlePath2 =
+            fs.path.join(targetDirectory.path, 'Payload', 'bundle2.app');
+        fs.directory(bundlePath1).createSync(recursive: true);
+        fs.directory(bundlePath2).createSync(recursive: true);
+      });
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('app.ipa');
+      expect(iosApp, isNull);
+      final BufferLogger logger = context[Logger];
+      expect(logger.errorText,
+          'Invalid prebuilt iOS ipa. Does not contain a single app bundle.\n');
+    }, overrides: overrides);
+    testUsingContext('Success with ipa', () {
+      fs.file('app.ipa').createSync();
+      when(os.unzip(any, any)).thenAnswer((Invocation invocation) {
+        final File zipFile = invocation.positionalArguments[0];
+        if (zipFile.path != 'app.ipa') {
+          return null;
+        }
+        final Directory targetDirectory = invocation.positionalArguments[1];
+        final Directory bundleAppDir = fs.directory(
+            fs.path.join(targetDirectory.path, 'Payload', 'bundle.app'));
+        bundleAppDir.createSync(recursive: true);
+        fs
+            .file(fs.path.join(bundleAppDir.path, 'Info.plist'))
+            .writeAsStringSync(plistData);
+      });
+      final PrebuiltIOSApp iosApp = new IOSApp.fromPrebuiltApp('app.ipa');
+      final BufferLogger logger = context[Logger];
+      expect(logger.errorText, isEmpty);
+      expect(iosApp.bundleDir.path, endsWith('bundle.app'));
+      expect(iosApp.id, 'fooBundleId');
+      expect(iosApp.bundleName, 'bundle.app');
+    }, overrides: overrides);
+  });
 }
 
 const String _aaptData = '''
@@ -69,3 +191,23 @@
   'SWIFT_OPTIMIZATION_LEVEL': '-Onone',
   'SWIFT_VERSION': '3.0',
 };
+
+class MockIosWorkFlow extends Mock implements IOSWorkflow {
+  @override
+  String getPlistValueFromFile(String path, String key) {
+    final File file = fs.file(path);
+    if (!file.existsSync()) {
+      return null;
+    }
+    return json.decode(file.readAsStringSync())[key];
+  }
+}
+
+// Contains no bundle identifier.
+const String badPlistData = '''
+{}
+''';
+
+const String plistData = '''
+{"CFBundleIdentifier": "fooBundleId"}
+''';