Add --create option to `flutter emulators` command (#18235)

* Add --create option to flutter emulators

* Tweaks to error message

* Simplify emulator search logic

* Make name optional

* Add a note about this option being used with --create

* Tweaks to help information

* Switch to processManager for easier testing

* Don't crash on missing files or missing properties in Android Emulator

* Move name suffixing into emulator manager

This allows it to be tested in the EmulatorManager tests and also used by daemon later if desired.

* Pass the context's android SDK through so it can be mocked by tests

* Misc fixes

* Add tests around emulator creation

Process calls are mocked to avoid needing a real SDK (and to be fast). Full integration tests may be useful, but may require ensuring all build environments etc. are set up correctly.

* Simplify avdManagerPath

Previous changes were to emulatorPath!

* Fix lint errors

* Fix incorrect file exgtension for Windows

* Fix an issue where no system images would crash

reduce throws on an empty collection.

* Fix "null" appearing in error messages

The name we attempted to use will now always be returned, even in the case of failure.

* Add additional info to missing-system-image failure message

On Windows after installing Andriod Studio I didn't have any of these and got this message. Installing with sdkmanager fixed the issue.

* Fix thrown errors

runResult had a toString() but we moved to ProcessResult when switching to ProcessManager to this ended up throwing "Instance of ProcessResult".

* Fix package import

* Fix more package imports

* Move mock implementation into Mock class

There seemed to be issues using Lists in args with Mockito that I couldn't figure out (docs say to use typed() but I couldn't make this compile with these lists still)..

* Rename method that's ambigious now we have create

* Handle where there's no avd path

* Add another toList() :(

* Remove comment that was rewritten

* Fix forbidden import

* Make optional arg more obviously optional

* Reformat doc

* Note that we create a pixel device in help text

* Make this a named arg
diff --git a/packages/flutter_tools/lib/src/android/android_emulator.dart b/packages/flutter_tools/lib/src/android/android_emulator.dart
index adb1ee7..582bf0f 100644
--- a/packages/flutter_tools/lib/src/android/android_emulator.dart
+++ b/packages/flutter_tools/lib/src/android/android_emulator.dart
@@ -9,7 +9,8 @@
 import '../android/android_sdk.dart';
 import '../android/android_workflow.dart';
 import '../base/file_system.dart';
-import '../base/process.dart';
+import '../base/io.dart';
+import '../base/process_manager.dart';
 import '../emulator.dart';
 import 'android_sdk.dart';
 
@@ -31,21 +32,23 @@
   Map<String, String> _properties;
 
   @override
-  String get name => _properties['hw.device.name'];
+  String get name => _prop('hw.device.name');
 
   @override
-  String get manufacturer => _properties['hw.device.manufacturer'];
+  String get manufacturer => _prop('hw.device.manufacturer');
 
   @override
   String get label => _properties['avd.ini.displayname'];
 
+  String _prop(String name) => _properties != null ? _properties[name] : null;
+
   @override
   Future<void> launch() async {
     final Future<void> launchResult =
-        runAsync(<String>[getEmulatorPath(), '-avd', id])
-            .then((RunResult runResult) {
+        processManager.run(<String>[getEmulatorPath(), '-avd', id])
+            .then((ProcessResult runResult) {
               if (runResult.exitCode != 0) {
-                throw '$runResult';
+                throw '${runResult.stdout}\n${runResult.stderr}'.trimRight();
               }
             });
     // emulator continues running on a successful launch so if we
@@ -65,10 +68,12 @@
     return <AndroidEmulator>[];
   }
 
-  final String listAvdsOutput = runSync(<String>[emulatorPath, '-list-avds']);
+  final String listAvdsOutput = processManager.runSync(<String>[emulatorPath, '-list-avds']).stdout;
 
   final List<AndroidEmulator> emulators = <AndroidEmulator>[];
-  extractEmulatorAvdInfo(listAvdsOutput, emulators);
+  if (listAvdsOutput != null) {
+    extractEmulatorAvdInfo(listAvdsOutput, emulators);
+  }
   return emulators;
 }
 
@@ -76,21 +81,26 @@
 /// of emulators by reading information from the relevant ini files.
 void extractEmulatorAvdInfo(String text, List<AndroidEmulator> emulators) {
   for (String id in text.trim().split('\n').where((String l) => l != '')) {
-    emulators.add(_createEmulator(id));
+    emulators.add(_loadEmulatorInfo(id));
   }
 }
 
-AndroidEmulator _createEmulator(String id) {
+AndroidEmulator _loadEmulatorInfo(String id) {
   id = id.trim();
-  final File iniFile = fs.file(fs.path.join(getAvdPath(), '$id.ini'));
-  final Map<String, String> ini = parseIniLines(iniFile.readAsLinesSync());
-
-  if (ini['path'] != null) {
-    final File configFile = fs.file(fs.path.join(ini['path'], 'config.ini'));
-    if (configFile.existsSync()) {
-      final Map<String, String> properties =
-          parseIniLines(configFile.readAsLinesSync());
-      return new AndroidEmulator(id, properties);
+  final String avdPath = getAvdPath();
+  if (avdPath != null) {
+    final File iniFile = fs.file(fs.path.join(avdPath, '$id.ini'));
+    if (iniFile.existsSync()) {
+      final Map<String, String> ini = parseIniLines(iniFile.readAsLinesSync());
+      if (ini['path'] != null) {
+        final File configFile =
+            fs.file(fs.path.join(ini['path'], 'config.ini'));
+        if (configFile.existsSync()) {
+          final Map<String, String> properties =
+              parseIniLines(configFile.readAsLinesSync());
+          return new AndroidEmulator(id, properties);
+        }
+      }
     }
   }
 
diff --git a/packages/flutter_tools/lib/src/android/android_sdk.dart b/packages/flutter_tools/lib/src/android/android_sdk.dart
index 79a5d67..2dcc5bf 100644
--- a/packages/flutter_tools/lib/src/android/android_sdk.dart
+++ b/packages/flutter_tools/lib/src/android/android_sdk.dart
@@ -64,16 +64,8 @@
 /// will work for those users who have Android Tools installed but
 /// not the full SDK.
 String getEmulatorPath([AndroidSdk existingSdk]) {
-  if (existingSdk?.emulatorPath != null)
-    return existingSdk.emulatorPath;
-
-  final AndroidSdk sdk = AndroidSdk.locateAndroidSdk();
-
-  if (sdk?.latestVersion == null) {
-    return os.which('emulator')?.path;
-  } else {
-    return sdk.emulatorPath;
-  }
+  return existingSdk?.emulatorPath ??
+    AndroidSdk.locateAndroidSdk()?.emulatorPath;
 }
 
 /// Locate the path for storing AVD emulator images. Returns null if none found.
@@ -104,6 +96,15 @@
   );
 }
 
+/// Locate 'avdmanager'. Prefer to use one from an Android SDK, if we can locate that.
+/// This should be used over accessing androidSdk.avdManagerPath directly because it
+/// will work for those users who have Android Tools installed but
+/// not the full SDK.
+String getAvdManagerPath([AndroidSdk existingSdk]) {
+  return existingSdk?.avdManagerPath ??
+    AndroidSdk.locateAndroidSdk()?.avdManagerPath;
+}
+
 class AndroidNdkSearchError {
   AndroidNdkSearchError(this.reason);
 
@@ -314,6 +315,8 @@
 
   String get emulatorPath => getEmulatorPath();
 
+  String get avdManagerPath => getAvdManagerPath();
+
   /// Validate the Android SDK. This returns an empty list if there are no
   /// issues; otherwise, it returns a list of issues found.
   List<String> validateSdkWellFormed() {
@@ -343,6 +346,14 @@
     return null;
   }
 
+  String getAvdManagerPath() {
+    final String binaryName = platform.isWindows ? 'avdmanager.bat' : 'avdmanager';
+    final String path = fs.path.join(directory, 'tools', 'bin', binaryName);
+    if (fs.file(path).existsSync())
+      return path;
+    return null;
+  }
+
   void _init() {
     Iterable<Directory> platforms = <Directory>[]; // android-22, ...
 
diff --git a/packages/flutter_tools/lib/src/commands/emulators.dart b/packages/flutter_tools/lib/src/commands/emulators.dart
index 78b1284..0904bc4 100644
--- a/packages/flutter_tools/lib/src/commands/emulators.dart
+++ b/packages/flutter_tools/lib/src/commands/emulators.dart
@@ -16,13 +16,18 @@
   EmulatorsCommand() {
     argParser.addOption('launch',
         help: 'The full or partial ID of the emulator to launch.');
+    argParser.addFlag('create',
+        help: 'Creates a new Android emulator based on a Pixel device.',
+        negatable: false);
+    argParser.addOption('name',
+        help: 'Used with flag --create. Specifies a name for the emulator being created.');
   }
 
   @override
   final String name = 'emulators';
 
   @override
-  final String description = 'List and launch available emulators.';
+  final String description = 'List, launch and create emulators.';
 
   @override
   final List<String> aliases = <String>['emulator'];
@@ -40,6 +45,8 @@
 
     if (argResults.wasParsed('launch')) {
       await _launchEmulator(argResults['launch']);
+    } else if (argResults.wasParsed('create')) {
+      await _createEmulator(name: argResults['name']);
     } else {
       final String searchText =
           argResults.rest != null && argResults.rest.isNotEmpty
@@ -70,17 +77,27 @@
     }
   }
 
+  Future<Null> _createEmulator({String name}) async {
+    final CreateEmulatorResult createResult =
+        await emulatorManager.createEmulator(name: name);
+
+    if (createResult.success) {
+      printStatus("Emulator '${createResult.emulatorName}' created successfully.");
+    } else {
+      printStatus("Failed to create emulator '${createResult.emulatorName}'.\n");
+      printStatus(createResult.error.trim());
+      _printAdditionalInfo();
+    }
+  }
+
   Future<void> _listEmulators(String searchText) async {
     final List<Emulator> emulators = searchText == null
         ? await emulatorManager.getAllAvailableEmulators()
         : await emulatorManager.getEmulatorsMatching(searchText);
 
     if (emulators.isEmpty) {
-      printStatus('No emulators available.\n\n'
-          // TODO(dantup): Change these when we support creation
-          // 'You may need to create images using "flutter emulators --create"\n'
-          'You may need to create one using Android Studio '
-          'or visit https://flutter.io/setup/ for troubleshooting tips.');
+      printStatus('No emulators available.');
+      _printAdditionalInfo(showCreateInstruction: true);
     } else {
       _printEmulatorList(
         emulators,
@@ -92,7 +109,28 @@
   void _printEmulatorList(List<Emulator> emulators, String message) {
     printStatus('$message\n');
     Emulator.printEmulators(emulators);
-    printStatus(
-        "\nTo run an emulator, run 'flutter emulators --launch <emulator id>'.");
+    _printAdditionalInfo(showCreateInstruction: true, showRunInstruction: true);
+  }
+
+  void _printAdditionalInfo({ bool showRunInstruction = false,
+      bool showCreateInstruction = false }) {
+    printStatus('');
+    if (showRunInstruction) {
+      printStatus(
+          "To run an emulator, run 'flutter emulators --launch <emulator id>'.");
+    }
+    if (showCreateInstruction) {
+      printStatus(
+          "To create a new emulator, run 'flutter emulators --create [--name xyz]'.");
+    }
+
+    if (showRunInstruction || showCreateInstruction) {
+      printStatus('');
+    }
+    // TODO(dantup): Update this link to flutter.io if/when we have a better page.
+    // That page can then link out to these places if required.
+    printStatus('You can find more information on managing emulators at the links below:\n'
+        '  https://developer.android.com/studio/run/managing-avds\n'
+        '  https://developer.android.com/studio/command-line/avdmanager');
   }
 }
diff --git a/packages/flutter_tools/lib/src/emulator.dart b/packages/flutter_tools/lib/src/emulator.dart
index 81bbe2a..5fa6b2a 100644
--- a/packages/flutter_tools/lib/src/emulator.dart
+++ b/packages/flutter_tools/lib/src/emulator.dart
@@ -6,7 +6,10 @@
 import 'dart:math' as math;
 
 import 'android/android_emulator.dart';
+import 'android/android_sdk.dart';
 import 'base/context.dart';
+import 'base/io.dart' show ProcessResult;
+import 'base/process_manager.dart';
 import 'globals.dart';
 import 'ios/ios_emulators.dart';
 
@@ -34,8 +37,8 @@
         emulator.id?.toLowerCase()?.startsWith(searchText) == true ||
         emulator.name?.toLowerCase()?.startsWith(searchText) == true;
 
-    final Emulator exactMatch = emulators.firstWhere(
-        exactlyMatchesEmulatorId, orElse: () => null);
+    final Emulator exactMatch =
+        emulators.firstWhere(exactlyMatchesEmulatorId, orElse: () => null);
     if (exactMatch != null) {
       return <Emulator>[exactMatch];
     }
@@ -57,6 +60,133 @@
     return emulators;
   }
 
+  /// Return the list of all available emulators.
+  Future<CreateEmulatorResult> createEmulator({String name}) async {
+    if (name == null || name == '') {
+      const String autoName = 'flutter_emulator';
+      // Don't use getEmulatorsMatching here, as it will only return one
+      // if there's an exact match and we need all those with this prefix
+      // so we can keep adding suffixes until we miss.
+      final List<Emulator> all = await getAllAvailableEmulators();
+      final Set<String> takenNames = all
+          .map((Emulator e) => e.id)
+          .where((String id) => id.startsWith(autoName))
+          .toSet();
+      int suffix = 1;
+      name = autoName;
+      while (takenNames.contains(name)) {
+        name = '${autoName}_${++suffix}';
+      }
+    }
+
+    final String device = await _getPreferredAvailableDevice();
+    if (device == null)
+      return new CreateEmulatorResult(name,
+          success: false, error: 'No device definitions are available');
+
+    final String sdkId = await _getPreferredSdkId();
+    if (sdkId == null)
+      return new CreateEmulatorResult(name,
+          success: false,
+          error:
+              'No suitable Android AVD system images are available. You may need to install these'
+              ' using sdkmanager, for example:\n'
+              '  sdkmanager "system-images;android-27;google_apis_playstore;x86"');
+
+    // Cleans up error output from avdmanager to make it more suitable to show
+    // to flutter users. Specifically:
+    // - Removes lines that say "null" (!)
+    // - Removes lines that tell the user to use '--force' to overwrite emulators
+    String cleanError(String error) {
+      return (error ?? '')
+          .split('\n')
+          .where((String l) => l.trim() != 'null')
+          .where((String l) =>
+              l.trim() != 'Use --force if you want to replace it.')
+          .join('\n');
+    }
+
+    final List<String> args = <String>[
+      getAvdManagerPath(androidSdk),
+      'create',
+      'avd',
+      '-n', name,
+      '-k', sdkId,
+      '-d', device
+    ];
+    final ProcessResult runResult = processManager.runSync(args);
+    return new CreateEmulatorResult(
+      name,
+      success: runResult.exitCode == 0,
+      output: runResult.stdout,
+      error: cleanError(runResult.stderr),
+    );
+  }
+
+  static const List<String> preferredDevices = const <String>[
+    'pixel',
+    'pixel_xl',
+  ];
+  Future<String> _getPreferredAvailableDevice() async {
+    final List<String> args = <String>[
+      getAvdManagerPath(androidSdk),
+      'list',
+      'device',
+      '-c'
+    ];
+    final ProcessResult runResult = processManager.runSync(args);
+    if (runResult.exitCode != 0)
+      return null;
+
+    final List<String> availableDevices = runResult.stdout
+        .split('\n')
+        .where((String l) => preferredDevices.contains(l.trim()))
+        .toList();
+
+    return preferredDevices.firstWhere(
+      (String d) => availableDevices.contains(d),
+      orElse: () => null,
+    );
+  }
+
+  RegExp androidApiVersion = new RegExp(r';android-(\d+);');
+  Future<String> _getPreferredSdkId() async {
+    // It seems that to get the available list of images, we need to send a
+    // request to create without the image and it'll provide us a list :-(
+    final List<String> args = <String>[
+      getAvdManagerPath(androidSdk),
+      'create',
+      'avd',
+      '-n', 'temp',
+    ];
+    final ProcessResult runResult = processManager.runSync(args);
+
+    // Get the list of IDs that match our criteria
+    final List<String> availableIDs = runResult.stderr
+        .split('\n')
+        .where((String l) => androidApiVersion.hasMatch(l))
+        .where((String l) => l.contains('system-images'))
+        .where((String l) => l.contains('google_apis_playstore'))
+        .toList();
+
+    final List<int> availableApiVersions = availableIDs
+        .map((String id) => androidApiVersion.firstMatch(id).group(1))
+        .map((String apiVersion) => int.parse(apiVersion))
+        .toList();
+
+    // Get the highest Android API version or whats left
+    final int apiVersion = availableApiVersions.isNotEmpty
+        ? availableApiVersions.reduce(math.max)
+        : -1; // Don't match below
+
+    // We're out of preferences, we just have to return the first one with the high
+    // API version.
+    return availableIDs.firstWhere(
+      (String id) => id.contains(';android-$apiVersion;'),
+      orElse: () => null,
+    );
+  }
+
   /// Whether we're capable of listing any emulators given the current environment configuration.
   bool get canListAnything {
     return _platformDiscoverers.any((EmulatorDiscovery discoverer) => discoverer.canListAnything);
@@ -124,11 +254,13 @@
 
     // Join columns into lines of text
     final RegExp whiteSpaceAndDots = new RegExp(r'[•\s]+$');
-    return table.map((List<String> row) {
-      return indices
-        .map((int i) => row[i].padRight(widths[i]))
-        .join(' • ') + ' • ${row.last}';
-    })
+    return table
+        .map((List<String> row) {
+          return indices
+                  .map((int i) => row[i].padRight(widths[i]))
+                  .join(' • ') +
+              ' • ${row.last}';
+        })
         .map((String line) => line.replaceAll(whiteSpaceAndDots, ''))
         .toList();
   }
@@ -137,3 +269,12 @@
     descriptions(emulators).forEach(printStatus);
   }
 }
+
+class CreateEmulatorResult {
+  final bool success;
+  final String emulatorName;
+  final String output;
+  final String error;
+
+  CreateEmulatorResult(this.emulatorName, {this.success, this.output, this.error});
+}
diff --git a/packages/flutter_tools/lib/src/ios/ios_emulators.dart b/packages/flutter_tools/lib/src/ios/ios_emulators.dart
index cd85c2f..3608bad 100644
--- a/packages/flutter_tools/lib/src/ios/ios_emulators.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_emulators.dart
@@ -71,6 +71,8 @@
 }
 
 String getSimulatorPath() {
+  if (xcode.xcodeSelectPath == null)
+    return null;
   final List<String> searchPaths = <String>[
     fs.path.join(xcode.xcodeSelectPath, 'Applications', 'Simulator.app'),
   ];