Add basic support for listing Android AVDs

Very basic support for "flutter emulators" which just lists the available Android AVDs.

Relates to:

https://github.com/flutter/flutter/issues/14822
https://github.com/Dart-Code/Dart-Code/issues/490
https://github.com/flutter/flutter/issues/13379
diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart
index 195fc4a..2d466d4 100644
--- a/packages/flutter_tools/lib/executable.dart
+++ b/packages/flutter_tools/lib/executable.dart
@@ -15,6 +15,7 @@
 import 'src/commands/devices.dart';
 import 'src/commands/doctor.dart';
 import 'src/commands/drive.dart';
+import 'src/commands/emulators.dart';
 import 'src/commands/format.dart';
 import 'src/commands/fuchsia_reload.dart';
 import 'src/commands/ide_config.dart';
@@ -57,6 +58,7 @@
     new DevicesCommand(),
     new DoctorCommand(verbose: verbose),
     new DriveCommand(),
+    new EmulatorsCommand(),
     new FormatCommand(),
     new FuchsiaReloadCommand(),
     new IdeConfigCommand(hidden: !verboseHelp),
diff --git a/packages/flutter_tools/lib/src/android/android_emulator.dart b/packages/flutter_tools/lib/src/android/android_emulator.dart
new file mode 100644
index 0000000..8429346
--- /dev/null
+++ b/packages/flutter_tools/lib/src/android/android_emulator.dart
@@ -0,0 +1,60 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:meta/meta.dart';
+
+import '../android/android_sdk.dart';
+import '../android/android_workflow.dart';
+import '../base/process.dart';
+import '../emulator.dart';
+import 'android_sdk.dart';
+
+class AndroidEmulators extends EmulatorDiscovery {
+  @override
+  bool get supportsPlatform => true;
+
+  @override
+  bool get canListAnything => androidWorkflow.canListDevices;
+
+  @override
+  Future<List<Emulator>> get emulators async => getEmulatorAvds();
+}
+
+class AndroidEmulator extends Emulator {
+  AndroidEmulator(
+    String id
+  ) : super(id);
+
+  @override
+  String get name => id;
+
+  // @override
+  // Future<bool> launch() async {
+  //   // TODO: ...
+  //   return null;Í
+  // }
+}
+
+/// Return the list of available emulator AVDs.
+List<AndroidEmulator> getEmulatorAvds() {
+  final String emulatorPath = getEmulatorPath(androidSdk);
+  if (emulatorPath == null)
+    return <AndroidEmulator>[];
+  final String text = runSync(<String>[emulatorPath, '-list-avds']);
+  final List<AndroidEmulator> devices = <AndroidEmulator>[];
+  parseEmulatorAvdOutput(text, devices);
+  return devices;
+}
+
+/// Parse the given `emulator -list-avds` output in [text], and fill out the given list
+/// of emulators.
+@visibleForTesting
+void parseEmulatorAvdOutput(String text,
+  List<AndroidEmulator> emulators) {
+  for (String line in text.trim().split('\n')) {
+    emulators.add(new AndroidEmulator(line));
+  }
+}
diff --git a/packages/flutter_tools/lib/src/android/android_sdk.dart b/packages/flutter_tools/lib/src/android/android_sdk.dart
index ddab13c..1875c48 100644
--- a/packages/flutter_tools/lib/src/android/android_sdk.dart
+++ b/packages/flutter_tools/lib/src/android/android_sdk.dart
@@ -59,6 +59,23 @@
   }
 }
 
+/// Locate ADB. Prefer to use one from an Android SDK, if we can locate that.
+/// This should be used over accessing androidSdk.adbPath directly because it
+/// will work for those users who have Android Platform 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;
+  }
+}
+
 class AndroidSdk {
   AndroidSdk(this.directory, [this.ndkDirectory, this.ndkCompiler,
       this.ndkCompilerArgs]) {
@@ -200,6 +217,8 @@
 
   String get adbPath => getPlatformToolsPath('adb');
 
+  String get emulatorPath => getToolsPath('emulator');
+
   /// 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() {
@@ -216,6 +235,10 @@
     return fs.path.join(directory, 'platform-tools', binaryName);
   }
 
+  String getToolsPath(String binaryName) {
+    return fs.path.join(directory, 'tools', binaryName);
+  }
+
   void _init() {
     Iterable<Directory> platforms = <Directory>[]; // android-22, ...
 
diff --git a/packages/flutter_tools/lib/src/android/android_workflow.dart b/packages/flutter_tools/lib/src/android/android_workflow.dart
index 14cc860..1187409 100644
--- a/packages/flutter_tools/lib/src/android/android_workflow.dart
+++ b/packages/flutter_tools/lib/src/android/android_workflow.dart
@@ -42,6 +42,12 @@
   @override
   bool get canLaunchDevices => androidSdk != null && androidSdk.validateSdkWellFormed().isEmpty;
 
+  @override
+  bool get canListEmulators => getEmulatorPath(androidSdk) != null;
+
+  @override
+  bool get canLaunchEmulators => androidSdk != null && androidSdk.validateSdkWellFormed().isEmpty;
+
   static const String _kJdkDownload = 'https://www.oracle.com/technetwork/java/javase/downloads/';
 
   /// Returns false if we cannot determine the Java version or if the version
diff --git a/packages/flutter_tools/lib/src/commands/emulators.dart b/packages/flutter_tools/lib/src/commands/emulators.dart
new file mode 100644
index 0000000..516ab3d
--- /dev/null
+++ b/packages/flutter_tools/lib/src/commands/emulators.dart
@@ -0,0 +1,51 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import '../base/common.dart';
+import '../base/utils.dart';
+import '../doctor.dart';
+import '../emulator.dart';
+import '../globals.dart';
+import '../runner/flutter_command.dart';
+
+class EmulatorsCommand extends FlutterCommand {
+  @override
+  final String name = 'emulators';
+
+  @override
+  final String description = 'List all available emulators.';
+
+  @override
+  Future<Null> runCommand() async {
+    if (!doctor.canListAnything) {
+      throwToolExit(
+        "Unable to locate emulators; please run 'flutter doctor' for "
+        'information about installing additional components.',
+        exitCode: 1);
+    }
+
+    final List<Emulator> emulators = await emulatorManager.getAllAvailableEmulators().toList();
+
+    if (emulators.isEmpty) {
+      printStatus(
+        'No emulators available.\n\n'
+        // TODO: 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\n'
+        'or visit https://flutter.io/setup/ for troubleshooting tips.');
+      final List<String> diagnostics = await emulatorManager.getEmulatorDiagnostics();
+      if (diagnostics.isNotEmpty) {
+        printStatus('');
+        for (String diagnostic in diagnostics) {
+          printStatus('• ${diagnostic.replaceAll('\n', '\n  ')}');
+        }
+      }
+    } else {
+      printStatus('${emulators.length} available ${pluralize('emulators', emulators.length)}:\n');
+      await Emulator.printEmulators(emulators);
+    }
+  }
+}
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index 5da0f2b..d5c23c3 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -26,6 +26,7 @@
 import 'devfs.dart';
 import 'device.dart';
 import 'doctor.dart';
+import 'emulator.dart';
 import 'ios/cocoapods.dart';
 import 'ios/ios_workflow.dart';
 import 'ios/mac.dart';
@@ -58,6 +59,7 @@
       DeviceManager: () => new DeviceManager(),
       Doctor: () => const Doctor(),
       DoctorValidatorsProvider: () => DoctorValidatorsProvider.defaultInstance,
+      EmulatorManager: () => new EmulatorManager(),
       Flags: () => const EmptyFlags(),
       FlutterVersion: () => new FlutterVersion(const Clock()),
       GenSnapshot: () => const GenSnapshot(),
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index bbb3f3a..e8677a2 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -209,6 +209,12 @@
 
   /// Could this thing launch *something*? It may still have minor issues.
   bool get canLaunchDevices;
+
+  /// Are we functional enough to list emulators?
+  bool get canListEmulators;
+
+  /// Could this thing launch *something*? It may still have minor issues.
+  bool get canLaunchEmulators;
 }
 
 enum ValidationType {
diff --git a/packages/flutter_tools/lib/src/emulator.dart b/packages/flutter_tools/lib/src/emulator.dart
new file mode 100644
index 0000000..673e4b5
--- /dev/null
+++ b/packages/flutter_tools/lib/src/emulator.dart
@@ -0,0 +1,168 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'android/android_emulator.dart';
+import 'base/context.dart';
+import 'globals.dart';
+
+EmulatorManager get emulatorManager => context[EmulatorManager];
+
+/// A class to get all available emulators.
+class EmulatorManager {
+  /// Constructing EmulatorManager is cheap; they only do expensive work if some
+  /// of their methods are called.
+  EmulatorManager() {
+    // Register the known discoverers.
+    _emulatorDiscoverers.add(new AndroidEmulators());
+  }
+
+  final List<EmulatorDiscovery> _emulatorDiscoverers = <EmulatorDiscovery>[];
+
+  String _specifiedEmulatorId;
+
+  /// A user-specified emulator ID.
+  String get specifiedEmulatorId {
+    if (_specifiedEmulatorId == null || _specifiedEmulatorId == 'all')
+      return null;
+    return _specifiedEmulatorId;
+  }
+
+  set specifiedEmulatorId(String id) {
+    _specifiedEmulatorId = id;
+  }
+
+  /// True when the user has specified a single specific emulator.
+  bool get hasSpecifiedEmulatorId => specifiedEmulatorId != null;
+
+  /// True when the user has specified all emulators by setting
+  /// specifiedEmulatorId = 'all'.
+  bool get hasSpecifiedAllEmulators => _specifiedEmulatorId == 'all';
+
+  Stream<Emulator> getEmulatorsById(String emulatorId) async* {
+    final List<Emulator> emulators = await getAllAvailableEmulators().toList();
+    emulatorId = emulatorId.toLowerCase();
+    bool exactlyMatchesEmulatorId(Emulator emulator) =>
+        emulator.id.toLowerCase() == emulatorId ||
+        emulator.name.toLowerCase() == emulatorId;
+    bool startsWithEmulatorId(Emulator emulator) =>
+        emulator.id.toLowerCase().startsWith(emulatorId) ||
+        emulator.name.toLowerCase().startsWith(emulatorId);
+
+    final Emulator exactMatch = emulators.firstWhere(
+        exactlyMatchesEmulatorId, orElse: () => null);
+    if (exactMatch != null) {
+      yield exactMatch;
+      return;
+    }
+
+    // Match on a id or name starting with [emulatorId].
+    for (Emulator emulator in emulators.where(startsWithEmulatorId))
+      yield emulator;
+  }
+
+  /// Return the list of available emulators, filtered by any user-specified emulator id.
+  Stream<Emulator> getEmulators() {
+    return hasSpecifiedEmulatorId
+        ? getEmulatorsById(specifiedEmulatorId)
+        : getAllAvailableEmulators();
+  }
+
+  Iterable<EmulatorDiscovery> get _platformDiscoverers {
+    return _emulatorDiscoverers.where((EmulatorDiscovery discoverer) => discoverer.supportsPlatform);
+  }
+
+  /// Return the list of all connected emulators.
+  Stream<Emulator> getAllAvailableEmulators() async* {
+    for (EmulatorDiscovery discoverer in _platformDiscoverers) {
+      for (Emulator emulator in await discoverer.emulators) {
+        yield emulator;
+      }
+    }
+  }
+
+  /// Whether we're capable of listing any emulators given the current environment configuration.
+  bool get canListAnything {
+    return _platformDiscoverers.any((EmulatorDiscovery discoverer) => discoverer.canListAnything);
+  }
+
+  /// Get diagnostics about issues with any emulators.
+  Future<List<String>> getEmulatorDiagnostics() async {
+    final List<String> diagnostics = <String>[];
+    for (EmulatorDiscovery discoverer in _platformDiscoverers) {
+      diagnostics.addAll(await discoverer.getDiagnostics());
+    }
+    return diagnostics;
+  }
+}
+
+/// An abstract class to discover and enumerate a specific type of emulators.
+abstract class EmulatorDiscovery {
+  bool get supportsPlatform;
+
+  /// Whether this emulator discovery is capable of listing any emulators given the
+  /// current environment configuration.
+  bool get canListAnything;
+
+  Future<List<Emulator>> get emulators;
+
+  /// Gets a list of diagnostic messages pertaining to issues with any available
+  /// emulators (will be an empty list if there are no issues).
+  Future<List<String>> getDiagnostics() => new Future<List<String>>.value(<String>[]);
+}
+
+abstract class Emulator {
+  Emulator(this.id);
+
+  final String id;
+
+  String get name;
+
+  @override
+  int get hashCode => id.hashCode;
+
+  @override
+  bool operator ==(dynamic other) {
+    if (identical(this, other))
+      return true;
+    if (other is! Emulator)
+      return false;
+    return id == other.id;
+  }
+
+  @override
+  String toString() => name;
+
+  static Stream<String> descriptions(List<Emulator> emulators) async* {
+    if (emulators.isEmpty)
+      return;
+
+    // Extract emulators information
+    final List<List<String>> table = <List<String>>[];
+    for (Emulator emulator in emulators) {
+      table.add(<String>[
+        emulator.name,
+        emulator.id,
+      ]);
+    }
+
+    // Calculate column widths
+    final List<int> indices = new List<int>.generate(table[0].length - 1, (int i) => i);
+    List<int> widths = indices.map((int i) => 0).toList();
+    for (List<String> row in table) {
+      widths = indices.map((int i) => math.max(widths[i], row[i].length)).toList();
+    }
+
+    // Join columns into lines of text
+    for (List<String> row in table) {
+      yield indices.map((int i) => row[i].padRight(widths[i])).join(' • ') + ' • ${row.last}';
+    }
+  }
+
+  static Future<Null> printEmulators(List<Emulator> emulators) async {
+    await descriptions(emulators).forEach(printStatus);
+  }
+}
diff --git a/packages/flutter_tools/lib/src/ios/ios_workflow.dart b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
index 8c87671..f928383 100644
--- a/packages/flutter_tools/lib/src/ios/ios_workflow.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
@@ -30,6 +30,12 @@
   @override
   bool get canLaunchDevices => xcode.isInstalledAndMeetsVersionCheck;
 
+  @override
+  bool get canListEmulators => false;
+
+  @override
+  bool get canLaunchEmulators => false;
+
   Future<bool> get hasIDeviceInstaller => exitsHappyAsync(<String>['ideviceinstaller', '-h']);
 
   Future<bool> get hasIosDeploy => exitsHappyAsync(<String>['ios-deploy', '--version']);