Android license detector in doctor, take two (#14783)

* Revert "Revert "Add android license verification to doctor and some refactoring" (#14727)"

This reverts commit d26029475222e8b1c7d8b80072c6b6261e2c3551.

* Add tests, fix sdkManagerEnv and use it consistently, and rearrange Status object model

* AnsiSpinner needs to leave the cursor where it found it.

* fix tests

* Const constructor warning only shows up on windows...?

* Avoid crash if we can't find the home directory

* Make pathVarSeparator return a string in the mock

* Implement review comments

* Fix out-of-order problem on stop
diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart
index f4dfb5c..d1c0139 100644
--- a/packages/flutter_tools/lib/runner.dart
+++ b/packages/flutter_tools/lib/runner.dart
@@ -234,7 +234,7 @@
 
     appContext.setVariable(Logger, logger);
 
-    await appContext.runInZone(() => doctor.diagnose());
+    await appContext.runInZone(() => doctor.diagnose(verbose: true));
 
     return logger.statusText;
   } catch (error, trace) {
diff --git a/packages/flutter_tools/lib/src/android/android_sdk.dart b/packages/flutter_tools/lib/src/android/android_sdk.dart
index a024d73..e3a7dc5 100644
--- a/packages/flutter_tools/lib/src/android/android_sdk.dart
+++ b/packages/flutter_tools/lib/src/android/android_sdk.dart
@@ -12,9 +12,11 @@
 import '../base/io.dart' show ProcessResult;
 import '../base/os.dart';
 import '../base/platform.dart';
+import '../base/process.dart';
 import '../base/process_manager.dart';
 import '../base/version.dart';
 import '../globals.dart';
+import 'android_studio.dart' as android_studio;
 
 AndroidSdk get androidSdk => context[AndroidSdk];
 
@@ -63,6 +65,9 @@
     _init();
   }
 
+  static const String _kJavaHomeEnvironmentVariable = 'JAVA_HOME';
+  static const String _kJavaExecutable = 'java';
+
   /// The path to the Android SDK.
   final String directory;
 
@@ -291,11 +296,56 @@
     return fs.path.join(directory, 'tools', 'bin', 'sdkmanager');
   }
 
+  /// First try Java bundled with Android Studio, then sniff JAVA_HOME, then fallback to PATH.
+  static String findJavaBinary() {
+
+    if (android_studio.javaPath != null)
+      return fs.path.join(android_studio.javaPath, 'bin', 'java');
+
+    final String javaHomeEnv = platform.environment[_kJavaHomeEnvironmentVariable];
+    if (javaHomeEnv != null) {
+      // Trust JAVA_HOME.
+      return fs.path.join(javaHomeEnv, 'bin', 'java');
+    }
+
+    // MacOS specific logic to avoid popping up a dialog window.
+    // See: http://stackoverflow.com/questions/14292698/how-do-i-check-if-the-java-jdk-is-installed-on-mac.
+    if (platform.isMacOS) {
+      try {
+        final String javaHomeOutput = runCheckedSync(<String>['/usr/libexec/java_home'], hideStdout: true);
+        if (javaHomeOutput != null) {
+          final List<String> javaHomeOutputSplit = javaHomeOutput.split('\n');
+          if ((javaHomeOutputSplit != null) && (javaHomeOutputSplit.isNotEmpty)) {
+            final String javaHome = javaHomeOutputSplit[0].trim();
+            return fs.path.join(javaHome, 'bin', 'java');
+          }
+        }
+      } catch (_) { /* ignore */ }
+    }
+
+    // Fallback to PATH based lookup.
+    return os.which(_kJavaExecutable)?.path;
+  }
+
+  Map<String, String> _sdkManagerEnv;
+  Map<String, String> get sdkManagerEnv {
+    if (_sdkManagerEnv == null) {
+      // If we can locate Java, then add it to the path used to run the Android SDK manager.
+      _sdkManagerEnv = <String, String>{};
+      final String javaBinary = findJavaBinary();
+      if (javaBinary != null) {
+        _sdkManagerEnv['PATH'] =
+            fs.path.dirname(javaBinary) + os.pathVarSeparator + platform.environment['PATH'];
+      }
+    }
+    return _sdkManagerEnv;
+  }
+
   /// Returns the version of the Android SDK manager tool or null if not found.
   String get sdkManagerVersion {
     if (!processManager.canRun(sdkManagerPath))
       throwToolExit('Android sdkmanager not found. Update to the latest Android SDK to resolve this.');
-    final ProcessResult result = processManager.runSync(<String>[sdkManagerPath, '--version']);
+    final ProcessResult result = processManager.runSync(<String>[sdkManagerPath, '--version'], environment: sdkManagerEnv);
     if (result.exitCode != 0) {
       throwToolExit('sdkmanager --version failed: ${result.exitCode}', exitCode: result.exitCode);
     }
diff --git a/packages/flutter_tools/lib/src/android/android_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart
index b9e0c30..3877aac 100644
--- a/packages/flutter_tools/lib/src/android/android_studio.dart
+++ b/packages/flutter_tools/lib/src/android/android_studio.dart
@@ -178,14 +178,14 @@
 
     // Read all $HOME/.AndroidStudio*/system/.home files. There may be several
     // pointing to the same installation, so we grab only the latest one.
-    for (FileSystemEntity entity in fs.directory(homeDirPath).listSync()) {
-      if (entity is Directory && entity.basename.startsWith('.AndroidStudio')) {
-        final AndroidStudio studio = new AndroidStudio.fromHomeDot(entity);
-        if (studio != null &&
-            !_hasStudioAt(studio.directory, newerThan: studio.version)) {
-          studios.removeWhere(
-              (AndroidStudio other) => other.directory == studio.directory);
-          studios.add(studio);
+    if (fs.directory(homeDirPath).existsSync()) {
+      for (FileSystemEntity entity in fs.directory(homeDirPath).listSync()) {
+        if (entity is Directory && entity.basename.startsWith('.AndroidStudio')) {
+          final AndroidStudio studio = new AndroidStudio.fromHomeDot(entity);
+          if (studio != null && !_hasStudioAt(studio.directory, newerThan: studio.version)) {
+            studios.removeWhere((AndroidStudio other) => other.directory == studio.directory);
+            studios.add(studio);
+          }
         }
       }
     }
diff --git a/packages/flutter_tools/lib/src/android/android_workflow.dart b/packages/flutter_tools/lib/src/android/android_workflow.dart
index 18673de..3c900f9 100644
--- a/packages/flutter_tools/lib/src/android/android_workflow.dart
+++ b/packages/flutter_tools/lib/src/android/android_workflow.dart
@@ -3,12 +3,11 @@
 // found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 
 import '../base/common.dart';
 import '../base/context.dart';
-import '../base/file_system.dart';
 import '../base/io.dart';
-import '../base/os.dart';
 import '../base/platform.dart';
 import '../base/process.dart';
 import '../base/process_manager.dart';
@@ -17,10 +16,20 @@
 import '../doctor.dart';
 import '../globals.dart';
 import 'android_sdk.dart';
-import 'android_studio.dart' as android_studio;
 
 AndroidWorkflow get androidWorkflow => context.putIfAbsent(AndroidWorkflow, () => new AndroidWorkflow());
 
+enum LicensesAccepted {
+  none,
+  some,
+  all,
+  unknown,
+}
+
+final RegExp licenseCounts = new RegExp(r'(\d+) of (\d+) SDK package licenses? not accepted.');
+final RegExp licenseNotAccepted = new RegExp(r'licenses? not accepted', caseSensitive: false);
+final RegExp licenseAccepted = new RegExp(r'All SDK package licenses accepted.');
+
 class AndroidWorkflow extends DoctorValidator implements Workflow {
   AndroidWorkflow() : super('Android toolchain - develop for Android devices');
 
@@ -33,41 +42,8 @@
   @override
   bool get canLaunchDevices => androidSdk != null && androidSdk.validateSdkWellFormed().isEmpty;
 
-  static const String _kJavaHomeEnvironmentVariable = 'JAVA_HOME';
-  static const String _kJavaExecutable = 'java';
   static const String _kJdkDownload = 'https://www.oracle.com/technetwork/java/javase/downloads/';
 
-  /// First try Java bundled with Android Studio, then sniff JAVA_HOME, then fallback to PATH.
-  static String _findJavaBinary() {
-
-    if (android_studio.javaPath != null)
-      return fs.path.join(android_studio.javaPath, 'bin', 'java');
-
-    final String javaHomeEnv = platform.environment[_kJavaHomeEnvironmentVariable];
-    if (javaHomeEnv != null) {
-      // Trust JAVA_HOME.
-      return fs.path.join(javaHomeEnv, 'bin', 'java');
-    }
-
-    // MacOS specific logic to avoid popping up a dialog window.
-    // See: http://stackoverflow.com/questions/14292698/how-do-i-check-if-the-java-jdk-is-installed-on-mac.
-    if (platform.isMacOS) {
-      try {
-        final String javaHomeOutput = runCheckedSync(<String>['/usr/libexec/java_home'], hideStdout: true);
-        if (javaHomeOutput != null) {
-          final List<String> javaHomeOutputSplit = javaHomeOutput.split('\n');
-          if ((javaHomeOutputSplit != null) && (javaHomeOutputSplit.isNotEmpty)) {
-            final String javaHome = javaHomeOutputSplit[0].trim();
-            return fs.path.join(javaHome, 'bin', 'java');
-          }
-        }
-      } catch (_) { /* ignore */ }
-    }
-
-    // Fallback to PATH based lookup.
-    return os.which(_kJavaExecutable)?.path;
-  }
-
   /// Returns false if we cannot determine the Java version or if the version
   /// is not compatible.
   bool _checkJavaVersion(String javaBinary, List<ValidationMessage> messages) {
@@ -154,7 +130,7 @@
     }
 
     // Now check for the JDK.
-    final String javaBinary = _findJavaBinary();
+    final String javaBinary = AndroidSdk.findJavaBinary();
     if (javaBinary == null) {
       messages.add(new ValidationMessage.error(
           'No Java Development Kit (JDK) found; You must have the environment '
@@ -169,10 +145,59 @@
       return new ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
     }
 
+    // Check for licenses.
+    switch (await licensesAccepted) {
+      case LicensesAccepted.all:
+        messages.add(new ValidationMessage('All Android licenses accepted.'));
+        break;
+      case LicensesAccepted.some:
+        messages.add(new ValidationMessage.hint('Some Android licenses not accepted.  To resolve this, run: flutter doctor --android-licenses'));
+        return new ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
+      case LicensesAccepted.none:
+        messages.add(new ValidationMessage.error('Android licenses not accepted.  To resolve this, run: flutter doctor --android-licenses'));
+        return new ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
+      case LicensesAccepted.unknown:
+        messages.add(new ValidationMessage.error('Android license status unknown.'));
+        return new ValidationResult(ValidationType.partial, messages, statusInfo: sdkVersionText);
+    }
+
     // Success.
     return new ValidationResult(ValidationType.installed, messages, statusInfo: sdkVersionText);
   }
 
+  Future<LicensesAccepted> get licensesAccepted async {
+    LicensesAccepted status = LicensesAccepted.unknown;
+
+    void _onLine(String line) {
+      if (licenseAccepted.hasMatch(line)) {
+        status = LicensesAccepted.all;
+      } else if (licenseCounts.hasMatch(line)) {
+        final Match match = licenseCounts.firstMatch(line);
+        if (match.group(1) != match.group(2)) {
+          status = LicensesAccepted.some;
+        } else {
+          status = LicensesAccepted.none;
+        }
+      } else if (licenseNotAccepted.hasMatch(line)) {
+        // In case the format changes, a more general match will keep doctor
+        // mostly working.
+        status = LicensesAccepted.none;
+      }
+    }
+
+    final Process process = await runCommand(<String>[androidSdk.sdkManagerPath, '--licenses'], environment: androidSdk.sdkManagerEnv);
+    process.stdin.write('n\n');
+    final Future<void> output = process.stdout.transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter()).listen(_onLine).asFuture<void>(null);
+    final Future<void> errors = process.stderr.transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter()).listen(_onLine).asFuture<void>(null);
+    try {
+      await Future.wait<void>(<Future<void>>[output, errors]).timeout(const Duration(seconds: 30));
+    } catch (TimeoutException) {
+      printTrace('Intentionally killing ${androidSdk.sdkManagerPath}');
+      processManager.killPid(process.pid);
+    }
+    return status;
+  }
+
   /// Run the Android SDK manager tool in order to accept SDK licenses.
   static Future<bool> runLicenseManager() async {
     if (androidSdk == null) {
@@ -180,14 +205,6 @@
       return false;
     }
 
-    // If we can locate Java, then add it to the path used to run the Android SDK manager.
-    final Map<String, String> sdkManagerEnv = <String, String>{};
-    final String javaBinary = _findJavaBinary();
-    if (javaBinary != null) {
-      sdkManagerEnv['PATH'] =
-          fs.path.dirname(javaBinary) + os.pathVarSeparator + platform.environment['PATH'];
-    }
-
     if (!processManager.canRun(androidSdk.sdkManagerPath))
       throwToolExit(
         'Android sdkmanager tool not found.\n'
@@ -205,7 +222,7 @@
 
     final Process process = await runCommand(
       <String>[androidSdk.sdkManagerPath, '--licenses'],
-      environment: sdkManagerEnv,
+      environment: androidSdk.sdkManagerEnv,
     );
 
     waitGroup<Null>(<Future<Null>>[
diff --git a/packages/flutter_tools/lib/src/base/logger.dart b/packages/flutter_tools/lib/src/base/logger.dart
index 0493edb..9cdbd09 100644
--- a/packages/flutter_tools/lib/src/base/logger.dart
+++ b/packages/flutter_tools/lib/src/base/logger.dart
@@ -11,6 +11,8 @@
 import 'terminal.dart';
 import 'utils.dart';
 
+const int kDefaultStatusPadding = 59;
+
 abstract class Logger {
   bool get isVerbose => false;
 
@@ -47,15 +49,10 @@
     String message, {
     String progressId,
     bool expectSlowOperation: false,
-    int progressIndicatorPadding: 52,
+    int progressIndicatorPadding: kDefaultStatusPadding,
   });
 }
 
-class Status {
-  void stop() { }
-  void cancel() { }
-}
-
 typedef void _FinishCallback();
 
 class StdoutLogger extends Logger {
@@ -108,25 +105,19 @@
     String message, {
     String progressId,
     bool expectSlowOperation: false,
-    int progressIndicatorPadding: 52,
+    int progressIndicatorPadding: 59,
   }) {
     if (_status != null) {
       // Ignore nested progresses; return a no-op status object.
-      return new Status();
-    } else {
-      if (supportsColor) {
-        _status = new _AnsiStatus(
-          message,
-          expectSlowOperation,
-          () { _status = null; },
-          progressIndicatorPadding,
-        );
-        return _status;
-      } else {
-        printStatus(message);
-        return new Status();
-      }
+      return new Status()..start();
     }
+    if (terminal.supportsColor) {
+      _status = new AnsiStatus(message, expectSlowOperation, () { _status = null; }, progressIndicatorPadding)..start();
+    } else {
+      printStatus(message);
+      _status = new Status()..start();
+    }
+    return _status;
   }
 }
 
@@ -142,6 +133,7 @@
 
   @override
   void writeToStdOut(String message) {
+    // TODO(jcollins-g): wrong abstraction layer for this, move to [Stdio].
     stdout.write(message
         .replaceAll('✗', 'X')
         .replaceAll('✓', '√')
@@ -185,7 +177,7 @@
     String message, {
     String progressId,
     bool expectSlowOperation: false,
-    int progressIndicatorPadding: 52,
+    int progressIndicatorPadding: kDefaultStatusPadding,
   }) {
     printStatus(message);
     return new Status();
@@ -235,7 +227,7 @@
     String message, {
     String progressId,
     bool expectSlowOperation: false,
-    int progressIndicatorPadding: 52,
+    int progressIndicatorPadding: kDefaultStatusPadding,
   }) {
     printStatus(message);
     return new Status();
@@ -280,57 +272,140 @@
   trace
 }
 
-class _AnsiStatus extends Status {
-  _AnsiStatus(this.message, this.expectSlowOperation, this.onFinish, int padding) {
-    stopwatch = new Stopwatch()..start();
+/// A [Status] class begins when start is called, and may produce progress
+/// information asynchronously.
+///
+/// When stop is called, summary information supported by this class is printed.
+/// If cancel is called, no summary information is displayed.
+/// The base class displays nothing at all.
+class Status {
+  Status();
 
-    stdout.write('${message.padRight(padding)}     ');
-    stdout.write('${_progress[0]}');
+  bool _isStarted = false;
 
+  factory Status.withSpinner() {
+    if (terminal.supportsColor)
+      return new AnsiSpinner()..start();
+    return new Status()..start();
+  }
+
+  /// Display summary information for this spinner; called by [stop].
+  void summaryInformation() {}
+
+  /// Call to start spinning.  Call this method via super at the beginning
+  /// of a subclass [start] method.
+  void start() {
+    _isStarted = true;
+  }
+
+  /// Call to stop spinning and delete the spinner.  Print summary information,
+  /// if applicable to the spinner.
+  void stop() {
+    if (_isStarted) {
+      cancel();
+      summaryInformation();
+    }
+  }
+
+  /// Call to cancel the spinner without printing any summary output.  Call
+  /// this method via super at the end of a subclass [cancel] method.
+  void cancel() {
+    _isStarted = false;
+  }
+}
+
+/// An [AnsiSpinner] is a simple animation that does nothing but implement an
+/// ASCII spinner.  When stopped or canceled, the animation erases itself.
+class AnsiSpinner extends Status {
+  int ticks = 0;
+  Timer timer;
+
+  static final List<String> _progress = <String>['-', r'\', '|', r'/'];
+
+  void _callback(Timer _) {
+    stdout.write('\b${_progress[ticks++ % _progress.length]}');
+  }
+
+  @override
+  void start() {
+    super.start();
+    stdout.write(' ');
+    _callback(null);
     timer = new Timer.periodic(const Duration(milliseconds: 100), _callback);
   }
 
-  static final List<String> _progress = <String>['-', r'\', '|', r'/', '-', r'\', '|', '/'];
+  @override
+  /// Clears the spinner.  After cancel, the cursor will be one space right
+  /// of where it was when [start] was called (assuming no other input).
+  void cancel() {
+    if (timer?.isActive == true) {
+      timer.cancel();
+      // Many terminals do not interpret backspace as deleting a character,
+      // but rather just moving the cursor back one.
+      stdout.write('\b \b');
+    }
+    super.cancel();
+  }
+}
+
+/// Constructor writes [message] to [stdout] with padding, then starts as an
+/// [AnsiSpinner].  On [cancel] or [stop], will call [onFinish].
+/// On [stop], will additionally print out summary information in
+/// milliseconds if [expectSlowOperation] is false, as seconds otherwise.
+class AnsiStatus extends AnsiSpinner {
+  AnsiStatus(this.message, this.expectSlowOperation, this.onFinish, this.padding);
 
   final String message;
   final bool expectSlowOperation;
   final _FinishCallback onFinish;
+  final int padding;
+
   Stopwatch stopwatch;
-  Timer timer;
-  int index = 1;
-  bool live = true;
+  bool _finished = false;
 
-  void _callback(Timer timer) {
-    stdout.write('\b${_progress[index]}');
-    index = ++index % _progress.length;
+  @override
+  /// Writes [message] to [stdout] with padding, then begins spinning.
+  void start() {
+    stopwatch = new Stopwatch()..start();
+    stdout.write('${message.padRight(padding)}     ');
+    assert(!_finished);
+    super.start();
   }
 
   @override
+  /// Calls onFinish.
   void stop() {
-    onFinish();
-
-    if (!live)
-      return;
-    live = false;
-
-    if (expectSlowOperation) {
-      print('\b\b\b\b\b${getElapsedAsSeconds(stopwatch.elapsed).padLeft(5)}');
-    } else {
-      print('\b\b\b\b\b${getElapsedAsMilliseconds(stopwatch.elapsed).padLeft(5)}');
+    if (!_finished) {
+      onFinish();
+      _finished = true;
+      super.cancel();
+      summaryInformation();
     }
-
-    timer.cancel();
   }
 
   @override
+  /// Backs up 4 characters and prints a (minimum) 5 character padded time.  If
+  /// [expectSlowOperation] is true, the time is in seconds; otherwise,
+  /// milliseconds.  Only backs up 4 characters because [super.cancel] backs
+  /// up one.
+  ///
+  /// Example: '\b\b\b\b 0.5s', '\b\b\b\b150ms', '\b\b\b\b1600ms'
+  void summaryInformation() {
+    if (expectSlowOperation) {
+      stdout.writeln('\b\b\b\b${getElapsedAsSeconds(stopwatch.elapsed).padLeft(5)}');
+    } else {
+      stdout.writeln('\b\b\b\b${getElapsedAsMilliseconds(stopwatch.elapsed).padLeft(5)}');
+    }
+  }
+
+  @override
+  /// Calls [onFinish].
   void cancel() {
-    onFinish();
-
-    if (!live)
-      return;
-    live = false;
-
-    print('\b ');
-    timer.cancel();
+    if (!_finished) {
+      onFinish();
+      _finished = true;
+      super.cancel();
+      stdout.write('\n');
+    }
   }
 }
diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart
index cbc1aae..94375b7 100644
--- a/packages/flutter_tools/lib/src/base/process.dart
+++ b/packages/flutter_tools/lib/src/base/process.dart
@@ -17,6 +17,10 @@
 typedef Future<dynamic> ShutdownHook();
 
 // TODO(ianh): We have way too many ways to run subprocesses in this project.
+// Convert most of these into one or more lightweight wrappers around the
+// [ProcessManager] API using named parameters for the various options.
+// See [here](https://github.com/flutter/flutter/pull/14535#discussion_r167041161)
+// for more details.
 
 /// The stage in which a [ShutdownHook] will be run. All shutdown hooks within
 /// a given stage will be started in parallel and will be guaranteed to run to
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 9d61447..bbeb83b 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -764,7 +764,7 @@
     String message, {
     String progressId,
     bool expectSlowOperation: false,
-    int progressIndicatorPadding: 52,
+    int progressIndicatorPadding: kDefaultStatusPadding,
   }) {
     printStatus(message);
     return new Status();
@@ -906,7 +906,7 @@
   }
 }
 
-class _AppLoggerStatus implements Status {
+class _AppLoggerStatus extends Status {
   _AppLoggerStatus(this.logger, this.id, this.progressId);
 
   final _AppRunLogger logger;
@@ -914,6 +914,9 @@
   final String progressId;
 
   @override
+  void start() {}
+
+  @override
   void stop() {
     logger._status = null;
     _sendFinished();
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index dcb12b8..1fbc469 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -13,6 +13,7 @@
 import 'base/common.dart';
 import 'base/context.dart';
 import 'base/file_system.dart';
+import 'base/logger.dart';
 import 'base/os.dart';
 import 'base/platform.dart';
 import 'base/process_manager.dart';
@@ -27,6 +28,12 @@
 
 Doctor get doctor => context[Doctor];
 
+class ValidatorTask {
+  ValidatorTask(this.validator, this.result);
+  final DoctorValidator validator;
+  final Future<ValidationResult> result;
+}
+
 class Doctor {
   List<DoctorValidator> _validators;
 
@@ -56,6 +63,16 @@
     return _validators;
   }
 
+  /// Return a list of [ValidatorTask] objects and starts validation on all
+  /// objects in [validators].
+  List<ValidatorTask> startValidatorTasks() {
+    final List<ValidatorTask> tasks = <ValidatorTask>[];
+    for (DoctorValidator validator in validators) {
+      tasks.add(new ValidatorTask(validator, validator.validate()));
+    }
+    return tasks;
+  }
+
   List<Workflow> get workflows {
     return new List<Workflow>.from(validators.where((DoctorValidator validator) => validator is Workflow));
   }
@@ -108,9 +125,14 @@
     bool doctorResult = true;
     int issues = 0;
 
-    for (DoctorValidator validator in validators) {
-      final ValidationResult result = await validator.validate();
+    for (ValidatorTask validatorTask in startValidatorTasks()) {
+      final DoctorValidator validator = validatorTask.validator;
+      final Status status = new Status.withSpinner();
+      await (validatorTask.result).then<void>((_) {
+        status.stop();
+      }).whenComplete(status.cancel);
 
+      final ValidationResult result = await validatorTask.result;
       if (result.type == ValidationType.missing) {
         doctorResult = false;
       }
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index 98882b4..0d48146 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -366,7 +366,7 @@
           buildSubStatus = logger.startProgress(
             line,
             expectSlowOperation: true,
-            progressIndicatorPadding: 45,
+            progressIndicatorPadding: kDefaultStatusPadding - 7,
           );
         }
       }
@@ -393,7 +393,7 @@
   scriptOutputPipeTempDirectory?.deleteSync(recursive: true);
   printStatus(
     'Xcode build done',
-    ansiAlternative: 'Xcode build done'.padRight(53)
+    ansiAlternative: 'Xcode build done'.padRight(kDefaultStatusPadding + 1)
         + '${getElapsedAsSeconds(buildStopwatch.elapsed).padLeft(5)}',
   );
 
diff --git a/packages/flutter_tools/test/android/android_sdk_test.dart b/packages/flutter_tools/test/android/android_sdk_test.dart
index a4408fe..60d516e 100644
--- a/packages/flutter_tools/test/android/android_sdk_test.dart
+++ b/packages/flutter_tools/test/android/android_sdk_test.dart
@@ -73,7 +73,7 @@
 
       final AndroidSdk sdk = AndroidSdk.locateAndroidSdk();
       when(processManager.canRun(sdk.sdkManagerPath)).thenReturn(true);
-      when(processManager.runSync(<String>[sdk.sdkManagerPath, '--version']))
+      when(processManager.runSync(<String>[sdk.sdkManagerPath, '--version'], environment: argThat(isNotNull)))
           .thenReturn(new ProcessResult(1, 0, '26.1.1\n', ''));
       expect(sdk.sdkManagerVersion, '26.1.1');
     }, overrides: <Type, Generator>{
@@ -87,7 +87,7 @@
 
       final AndroidSdk sdk = AndroidSdk.locateAndroidSdk();
       when(processManager.canRun(sdk.sdkManagerPath)).thenReturn(true);
-      when(processManager.runSync(<String>[sdk.sdkManagerPath, '--version']))
+      when(processManager.runSync(<String>[sdk.sdkManagerPath, '--version'], environment: argThat(isNotNull)))
           .thenReturn(new ProcessResult(1, 1, '26.1.1\n', 'Mystery error'));
       expect(() => sdk.sdkManagerVersion, throwsToolExit(exitCode: 1));
     }, overrides: <Type, Generator>{
diff --git a/packages/flutter_tools/test/android/android_workflow_test.dart b/packages/flutter_tools/test/android/android_workflow_test.dart
index b2113ea..616bb5c 100644
--- a/packages/flutter_tools/test/android/android_workflow_test.dart
+++ b/packages/flutter_tools/test/android/android_workflow_test.dart
@@ -2,6 +2,8 @@
 // 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:file/memory.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:flutter_tools/src/base/io.dart';
@@ -14,7 +16,7 @@
 
 import '../src/common.dart';
 import '../src/context.dart';
-import '../src/mocks.dart' show MockAndroidSdk, MockProcessManager, MockStdio;
+import '../src/mocks.dart' show MockAndroidSdk, MockProcess, MockProcessManager, MockStdio;
 
 void main() {
   AndroidSdk sdk;
@@ -25,12 +27,93 @@
   setUp(() {
     sdk = new MockAndroidSdk();
     fs = new MemoryFileSystem();
+    fs.directory('/home/me').createSync(recursive: true);
     processManager = new MockProcessManager();
     stdio = new MockStdio();
   });
 
+  MockProcess Function(List<String>) processMetaFactory(List<String> stdout) {
+    final Stream<List<int>> stdoutStream = new Stream<List<int>>.fromIterable(
+        stdout.map((String s) => s.codeUnits));
+    return (List<String> command) => new MockProcess(stdout: stdoutStream);
+  }
+
+  testUsingContext('licensesAccepted handles garbage/no output', () async {
+    MockAndroidSdk.createSdkDirectory();
+    when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
+    final AndroidWorkflow androidWorkflow = new AndroidWorkflow();
+    final LicensesAccepted result = await(androidWorkflow.licensesAccepted);
+    expect(result, equals(LicensesAccepted.unknown));
+    expect(processManager.commands.first, equals('/foo/bar/sdkmanager'));
+    expect(processManager.commands.last, equals('--licenses'));
+  }, overrides: <Type, Generator>{
+    AndroidSdk: () => sdk,
+    FileSystem: () => fs,
+    Platform: () => new FakePlatform()..environment = <String, String>{'HOME': '/home/me'},
+    ProcessManager: () => processManager,
+    Stdio: () => stdio,
+  });
+
+  testUsingContext('licensesAccepted works for all licenses accepted', () async {
+    MockAndroidSdk.createSdkDirectory();
+    when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
+    processManager.processFactory = processMetaFactory(<String>[
+       '[=======================================] 100% Computing updates...             ',
+       'All SDK package licenses accepted.'
+    ]);
+
+    final AndroidWorkflow androidWorkflow = new AndroidWorkflow();
+    final LicensesAccepted result = await(androidWorkflow.licensesAccepted);
+    expect(result, equals(LicensesAccepted.all));
+  }, overrides: <Type, Generator>{
+    AndroidSdk: () => sdk,
+    FileSystem: () => fs,
+    Platform: () => new FakePlatform()..environment = <String, String>{'HOME': '/home/me'},
+    ProcessManager: () => processManager,
+    Stdio: () => stdio,
+  });
+
+  testUsingContext('licensesAccepted works for some licenses accepted', () async {
+    MockAndroidSdk.createSdkDirectory();
+    when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
+    processManager.processFactory = processMetaFactory(<String>[
+      '[=======================================] 100% Computing updates...             ',
+      '2 of 5 SDK package licenses not accepted.',
+      'Review licenses that have not been accepted (y/N)?',
+    ]);
+
+    final AndroidWorkflow androidWorkflow = new AndroidWorkflow();
+    final LicensesAccepted result = await(androidWorkflow.licensesAccepted);
+    expect(result, equals(LicensesAccepted.some));
+  }, overrides: <Type, Generator>{
+    AndroidSdk: () => sdk,
+    FileSystem: () => fs,
+    Platform: () => new FakePlatform()..environment = <String, String>{'HOME': '/home/me'},
+    ProcessManager: () => processManager,
+    Stdio: () => stdio,
+  });
+
+  testUsingContext('licensesAccepted works for no licenses accepted', () async {
+    MockAndroidSdk.createSdkDirectory();
+    when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
+    processManager.processFactory = processMetaFactory(<String>[
+      '[=======================================] 100% Computing updates...             ',
+      '5 of 5 SDK package licenses not accepted.',
+      'Review licenses that have not been accepted (y/N)?',
+    ]);
+
+    final AndroidWorkflow androidWorkflow = new AndroidWorkflow();
+    final LicensesAccepted result = await(androidWorkflow.licensesAccepted);
+    expect(result, equals(LicensesAccepted.none));
+  }, overrides: <Type, Generator>{
+    AndroidSdk: () => sdk,
+    FileSystem: () => fs,
+    Platform: () => new FakePlatform()..environment = <String, String>{'HOME': '/home/me'},
+    ProcessManager: () => processManager,
+    Stdio: () => stdio,
+  });
+
   testUsingContext('runLicenseManager succeeds for version >= 26', () async {
-    fs.directory('/home/me').createSync(recursive: true);
     MockAndroidSdk.createSdkDirectory();
     when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
     when(sdk.sdkManagerVersion).thenReturn('26.0.0');
@@ -45,7 +128,6 @@
   });
 
   testUsingContext('runLicenseManager errors for version < 26', () async {
-    fs.directory('/home/me').createSync(recursive: true);
     MockAndroidSdk.createSdkDirectory();
     when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
     when(sdk.sdkManagerVersion).thenReturn('25.0.0');
@@ -60,7 +142,6 @@
   });
 
   testUsingContext('runLicenseManager errors when sdkmanager is not found', () async {
-    fs.directory('/home/me').createSync(recursive: true);
     MockAndroidSdk.createSdkDirectory();
     when(sdk.sdkManagerPath).thenReturn('/foo/bar/sdkmanager');
     processManager.succeed = false;
diff --git a/packages/flutter_tools/test/base/logger_test.dart b/packages/flutter_tools/test/base/logger_test.dart
index 8131b0a..5fa9778 100644
--- a/packages/flutter_tools/test/base/logger_test.dart
+++ b/packages/flutter_tools/test/base/logger_test.dart
@@ -2,9 +2,15 @@
 // 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:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:test/test.dart';
 
+import '../src/context.dart';
+import '../src/mocks.dart';
+
 void main() {
   group('AppContext', () {
     test('error', () async {
@@ -22,4 +28,138 @@
       expect(mockLogger.errorText, matches(r'^\[ (?: {0,2}\+[0-9]{1,3} ms|       )\] Helpless!\n$'));
     });
   });
+
+  group('Spinners', () {
+    MockStdio mockStdio;
+    AnsiSpinner ansiSpinner;
+    AnsiStatus ansiStatus;
+    int called;
+    final RegExp secondDigits = new RegExp(r'[^\b]\b\b\b\b\b[0-9]+[.][0-9]+(?:s|ms)');
+
+    setUp(() {
+      mockStdio = new MockStdio();
+      ansiSpinner = new AnsiSpinner();
+      called = 0;
+      ansiStatus = new AnsiStatus('Hello world', true, () => called++, 20);
+    });
+
+    List<String> outputLines() => mockStdio.writtenToStdout.join('').split('\n');
+
+    Future<void> doWhile(bool doThis()) async {
+      return Future.doWhile(() async {
+        // Future.doWhile() isn't enough by itself, because the VM never gets
+        // around to scheduling the other tasks for some reason.
+        await new Future<void>.delayed(const Duration(milliseconds: 0));
+        return doThis();
+      });
+    }
+
+    testUsingContext('AnsiSpinner works', () async {
+      ansiSpinner.start();
+      await doWhile(() => ansiSpinner.ticks < 10);
+      List<String> lines = outputLines();
+      expect(lines[0], startsWith(' \b-\b\\\b|\b/\b-\b\\\b|\b/'));
+      expect(lines[0].endsWith('\n'), isFalse);
+      expect(lines.length, equals(1));
+      ansiSpinner.stop();
+      lines = outputLines();
+      expect(lines[0], endsWith('\b \b'));
+      expect(lines.length, equals(1));
+
+      // Verify that stopping multiple times doesn't clear multiple times.
+      ansiSpinner.stop();
+      lines = outputLines();
+      expect(lines[0].endsWith('\b \b '), isFalse);
+      expect(lines.length, equals(1));
+      ansiSpinner.cancel();
+      lines = outputLines();
+      expect(lines[0].endsWith('\b \b '), isFalse);
+      expect(lines.length, equals(1));
+    }, overrides: <Type, Generator>{Stdio: () => mockStdio});
+
+    testUsingContext('AnsiStatus works when cancelled', () async {
+      ansiStatus.start();
+      await doWhile(() => ansiStatus.ticks < 10);
+      List<String> lines = outputLines();
+      expect(lines[0], startsWith('Hello world               \b-\b\\\b|\b/\b-\b\\\b|\b/\b-'));
+      expect(lines[0].endsWith('\n'), isFalse);
+      expect(lines.length, equals(1));
+      ansiStatus.cancel();
+      lines = outputLines();
+      expect(lines[0], endsWith('\b \b'));
+      expect(lines.length, equals(2));
+      expect(called, equals(1));
+      ansiStatus.cancel();
+      lines = outputLines();
+      expect(lines[0].endsWith('\b \b\b \b'), isFalse);
+      expect(lines.length, equals(2));
+      expect(called, equals(1));
+      ansiStatus.stop();
+      lines = outputLines();
+      expect(lines[0].endsWith('\b \b\b \b'), isFalse);
+      expect(lines.length, equals(2));
+      expect(called, equals(1));
+    }, overrides: <Type, Generator>{Stdio: () => mockStdio});
+
+    testUsingContext('AnsiStatus works when stopped', () async {
+      ansiStatus.start();
+      await doWhile(() => ansiStatus.ticks < 10);
+      List<String> lines = outputLines();
+      expect(lines[0], startsWith('Hello world               \b-\b\\\b|\b/\b-\b\\\b|\b/\b-'));
+      expect(lines.length, equals(1));
+
+      // Verify a stop prints the time.
+      ansiStatus.stop();
+      lines = outputLines();
+      List<Match> matches = secondDigits.allMatches(lines[0]).toList();
+      expect(matches, isNotNull);
+      expect(matches, hasLength(1));
+      Match  match = matches.first;
+      expect(lines[0], endsWith(match.group(0)));
+      final String initialTime = match.group(0);
+      expect(called, equals(1));
+      expect(lines.length, equals(2));
+      expect(lines[1], equals(''));
+
+      // Verify stopping more than once generates no additional output.
+      ansiStatus.stop();
+      lines = outputLines();
+      matches = secondDigits.allMatches(lines[0]).toList();
+      expect(matches, hasLength(1));
+      match = matches.first;
+      expect(lines[0], endsWith(initialTime));
+      expect(called, equals(1));
+      expect(lines.length, equals(2));
+      expect(lines[1], equals(''));
+    }, overrides: <Type, Generator>{Stdio: () => mockStdio});
+
+    testUsingContext('AnsiStatus works when cancelled', () async {
+      ansiStatus.start();
+      await doWhile(() => ansiStatus.ticks < 10);
+      List<String> lines = outputLines();
+      expect(lines[0], startsWith('Hello world               \b-\b\\\b|\b/\b-\b\\\b|\b/\b-'));
+      expect(lines.length, equals(1));
+
+      // Verify a cancel does _not_ print the time and prints a newline.
+      ansiStatus.cancel();
+      lines = outputLines();
+      List<Match> matches = secondDigits.allMatches(lines[0]).toList();
+      expect(matches, isEmpty);
+      expect(lines[0], endsWith('\b \b'));
+      expect(called, equals(1));
+      // TODO(jcollins-g): Consider having status objects print the newline
+      // when canceled, or never printing a newline at all.
+      expect(lines.length, equals(2));
+
+      // Verifying calling stop after cancel doesn't print anything weird.
+      ansiStatus.stop();
+      lines = outputLines();
+      matches = secondDigits.allMatches(lines[0]).toList();
+      expect(matches, isEmpty);
+      expect(lines[0], endsWith('\b \b'));
+      expect(called, equals(1));
+      expect(lines[0], isNot(endsWith('\b \b\b \b')));
+      expect(lines.length, equals(2));
+    }, overrides: <Type, Generator>{Stdio: () => mockStdio});
+  });
 }
diff --git a/packages/flutter_tools/test/commands/create_test.dart b/packages/flutter_tools/test/commands/create_test.dart
index 9968247..a21ec7b 100644
--- a/packages/flutter_tools/test/commands/create_test.dart
+++ b/packages/flutter_tools/test/commands/create_test.dart
@@ -380,9 +380,8 @@
       final CommandRunner<Null> runner = createTestCommandRunner(command);
 
       await runner.run(<String>['create', '--pub', '--offline', projectDir.path]);
-      final List<String> commands = loggingProcessManager.commands;
-      expect(commands, contains(matches(r'dart-sdk[\\/]bin[\\/]pub')));
-      expect(commands, contains('--offline'));
+      expect(loggingProcessManager.commands.first, contains(matches(r'dart-sdk[\\/]bin[\\/]pub')));
+      expect(loggingProcessManager.commands.first, contains('--offline'));
     },
       timeout: allowForCreateFlutterProject,
       overrides: <Type, Generator>{
@@ -397,9 +396,8 @@
       final CommandRunner<Null> runner = createTestCommandRunner(command);
 
       await runner.run(<String>['create', '--pub', projectDir.path]);
-      final List<String> commands = loggingProcessManager.commands;
-      expect(commands, contains(matches(r'dart-sdk[\\/]bin[\\/]pub')));
-      expect(commands, isNot(contains('--offline')));
+      expect(loggingProcessManager.commands.first, contains(matches(r'dart-sdk[\\/]bin[\\/]pub')));
+      expect(loggingProcessManager.commands.first, isNot(contains('--offline')));
     },
       timeout: allowForCreateFlutterProject,
       overrides: <Type, Generator>{
@@ -494,9 +492,9 @@
 class MockFlutterVersion extends Mock implements FlutterVersion {}
 
 /// A ProcessManager that invokes a real process manager, but keeps
-/// the last commands sent to it.
+/// track of all commands sent to it.
 class LoggingProcessManager extends LocalProcessManager {
-  List<String> commands;
+  List<List<String>> commands = <List<String>>[];
 
   @override
   Future<Process> start(
@@ -507,7 +505,7 @@
       bool runInShell: false,
       ProcessStartMode mode: ProcessStartMode.NORMAL,
     }) {
-    commands = command;
+    commands.add(command);
     return super.start(
       command,
       workingDirectory: workingDirectory,
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index 2e338e3..0a9f64d 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -4,6 +4,7 @@
 
 import 'dart:async';
 
+import 'package:flutter_tools/src/android/android_workflow.dart';
 import 'package:flutter_tools/src/artifacts.dart';
 import 'package:flutter_tools/src/base/config.dart';
 import 'package:flutter_tools/src/base/context.dart';
@@ -199,6 +200,11 @@
   Future<List<String>> getDeviceDiagnostics() async => <String>[];
 }
 
+class MockAndroidWorkflowValidator extends AndroidWorkflow {
+  @override
+  Future<LicensesAccepted> get licensesAccepted async => LicensesAccepted.all;
+}
+
 class MockDoctor extends Doctor {
   // True for testing.
   @override
@@ -207,6 +213,20 @@
   // True for testing.
   @override
   bool get canLaunchAnything => true;
+
+  @override
+  /// Replaces the android workflow with a version that overrides licensesAccepted,
+  /// to prevent individual tests from having to mock out the process for
+  /// the Doctor.
+  List<DoctorValidator> get validators {
+    final List<DoctorValidator> superValidators = super.validators;
+    return superValidators.map((DoctorValidator v) {
+      if (v is AndroidWorkflow) {
+        return new MockAndroidWorkflowValidator();
+      }
+      return v;
+    }).toList();
+  }
 }
 
 class MockSimControl extends Mock implements SimControl {
@@ -221,6 +241,9 @@
 
   @override
   String get name => 'fake OS name and version';
+
+  @override
+  String get pathVarSeparator => ';';
 }
 
 class MockIOSSimulatorUtils extends Mock implements IOSSimulatorUtils {}