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 {}