Make DeviceDomain echo a msg if doctor says it can't list anything (#9749)

This message will be picked up by IntelliJ and shown to the user in a toast.
diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart
index 887a390..6ad1e79 100644
--- a/packages/flutter_tools/lib/executable.dart
+++ b/packages/flutter_tools/lib/executable.dart
@@ -5,7 +5,6 @@
 import 'dart:async';
 
 import 'package:args/command_runner.dart';
-import 'package:flutter_tools/src/version.dart';
 import 'package:intl/intl_standalone.dart' as intl;
 import 'package:meta/meta.dart';
 import 'package:process/process.dart';
@@ -53,6 +52,7 @@
 import 'src/runner/flutter_command.dart';
 import 'src/runner/flutter_command_runner.dart';
 import 'src/usage.dart';
+import 'src/version.dart';
 
 /// Main entry point for commands.
 ///
diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart
index b99cce6..a6ceda2 100644
--- a/packages/flutter_tools/lib/src/android/android_device.dart
+++ b/packages/flutter_tools/lib/src/android/android_device.dart
@@ -17,6 +17,7 @@
 import '../build_info.dart';
 import '../commands/build_apk.dart';
 import '../device.dart';
+import '../doctor.dart';
 import '../globals.dart';
 import '../protocol_discovery.dart';
 
@@ -27,12 +28,15 @@
 const String _defaultAdbPath = 'adb';
 
 class AndroidDevices extends PollingDeviceDiscovery {
-  AndroidDevices() : super('AndroidDevices');
+  AndroidDevices() : super('Android devices');
 
   @override
   bool get supportsPlatform => true;
 
   @override
+  bool get canListAnything => doctor.androidWorkflow.canListDevices;
+
+  @override
   List<Device> pollingGetDevices() => getAdbDevices();
 }
 
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 9c94345..d97cd6f 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -62,12 +62,7 @@
       final int code = await daemon.onExit;
       if (code != 0)
         throwToolExit('Daemon exited with non-zero exit code: $code', exitCode: code);
-    }, onError: _handleError);
-  }
-
-  dynamic _handleError(dynamic error, StackTrace stackTrace) {
-    printError('Error from flutter daemon: $error', stackTrace: stackTrace);
-    return null;
+    });
   }
 }
 
@@ -76,7 +71,9 @@
 typedef Future<dynamic> CommandHandler(Map<String, dynamic> args);
 
 class Daemon {
-  Daemon(Stream<Map<String, dynamic>> commandStream, this.sendCommand, {
+  Daemon(
+    Stream<Map<String, dynamic>> commandStream,
+    this.sendCommand, {
     this.daemonCommand,
     this.notifyingLogger,
     this.logToStdout: false
@@ -87,7 +84,7 @@
     _registerDomain(deviceDomain = new DeviceDomain(this));
 
     // Start listening.
-    commandStream.listen(
+    _commandSubscription = commandStream.listen(
       _handleRequest,
       onDone: () {
         if (!_onExitCompleter.isCompleted)
@@ -99,6 +96,7 @@
   DaemonDomain daemonDomain;
   AppDomain appDomain;
   DeviceDomain deviceDomain;
+  StreamSubscription<Map<String, dynamic>> _commandSubscription;
 
   final DispatchCommand sendCommand;
   final DaemonCommand daemonCommand;
@@ -143,10 +141,15 @@
 
   void _send(Map<String, dynamic> map) => sendCommand(map);
 
-  void shutdown() {
+  void shutdown({dynamic error}) {
+    _commandSubscription?.cancel();
     _domainMap.values.forEach((Domain domain) => domain.dispose());
-    if (!_onExitCompleter.isCompleted)
-      _onExitCompleter.complete(0);
+    if (!_onExitCompleter.isCompleted) {
+      if (error == null)
+        _onExitCompleter.complete(0);
+      else
+        _onExitCompleter.completeError(error);
+    }
   }
 }
 
@@ -539,6 +542,18 @@
     if (!discoverer.supportsPlatform)
       return;
 
+    if (!discoverer.canListAnything) {
+      sendEvent(
+        'daemon.showMessage',
+        <String, String>{
+          'title': 'Unable to list devices',
+          'message':
+              'Unable to discover ${discoverer.name}. Please run '
+              '"flutter doctor" to diagnose potential issues',
+        },
+      );
+    }
+
     _discoverers.add(discoverer);
 
     discoverer.onAdded.listen(_onDeviceEvent('device.added'));
@@ -650,7 +665,8 @@
   });
 
 void stdoutCommandResponse(Map<String, dynamic> command) {
-  stdout.writeln('[${JSON.encode(command, toEncodable: _jsonEncodeObject)}]');
+  final String encoded = JSON.encode(command, toEncodable: _jsonEncodeObject);
+  stdout.writeln('[$encoded]');
 }
 
 dynamic _jsonEncodeObject(dynamic object) {
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index b356690..6898806 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -92,6 +92,11 @@
 /// An abstract class to discover and enumerate a specific type of devices.
 abstract class DeviceDiscovery {
   bool get supportsPlatform;
+
+  /// Whether this device discovery is capable of listing any devices given the
+  /// current environment configuration.
+  bool get canListAnything;
+
   List<Device> get devices;
 }
 
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index f61d486..2b769f1 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -27,12 +27,15 @@
 const Duration kPortForwardTimeout = const Duration(seconds: 10);
 
 class IOSDevices extends PollingDeviceDiscovery {
-  IOSDevices() : super('IOSDevices');
+  IOSDevices() : super('iOS devices');
 
   @override
   bool get supportsPlatform => platform.isMacOS;
 
   @override
+  bool get canListAnything => doctor.iosWorkflow.canListDevices;
+
+  @override
   List<Device> pollingGetDevices() => IOSDevice.getAttachedDevices();
 }
 
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 1f5f3e7..c4721aa 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -16,6 +16,7 @@
 import '../base/process_manager.dart';
 import '../build_info.dart';
 import '../device.dart';
+import '../doctor.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
 import '../protocol_discovery.dart';
@@ -27,12 +28,15 @@
 const String _kFlutterTestDeviceSuffix = '(Flutter)';
 
 class IOSSimulators extends PollingDeviceDiscovery {
-  IOSSimulators() : super('IOSSimulators');
+  IOSSimulators() : super('iOS simulators');
 
   @override
   bool get supportsPlatform => platform.isMacOS;
 
   @override
+  bool get canListAnything => doctor.iosWorkflow.canListDevices;
+
+  @override
   List<Device> pollingGetDevices() => IOSSimulatorUtils.instance.getAttachedDevices();
 }
 
diff --git a/packages/flutter_tools/test/daemon_test.dart b/packages/flutter_tools/test/daemon_test.dart
index 1e9f382..eb72772 100644
--- a/packages/flutter_tools/test/daemon_test.dart
+++ b/packages/flutter_tools/test/daemon_test.dart
@@ -4,14 +4,13 @@
 
 import 'dart:async';
 
+import 'package:flutter_tools/src/android/android_workflow.dart';
 import 'package:flutter_tools/src/base/context.dart';
 import 'package:flutter_tools/src/base/logger.dart';
-import 'package:flutter_tools/src/base/platform.dart';
 import 'package:flutter_tools/src/commands/daemon.dart';
-import 'package:flutter_tools/src/device.dart';
 import 'package:flutter_tools/src/doctor.dart';
 import 'package:flutter_tools/src/globals.dart';
-import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/ios/ios_workflow.dart';
 import 'package:test/test.dart';
 
 import 'src/context.dart';
@@ -22,22 +21,11 @@
   AppContext appContext;
   NotifyingLogger notifyingLogger;
 
-  void _testUsingContext(String description, dynamic testMethod()) {
-    test(description, () {
-      return appContext.runInZone(testMethod);
-    });
-  }
-
   group('daemon', () {
     setUp(() {
       appContext = new AppContext();
       notifyingLogger = new NotifyingLogger();
-      appContext.setVariable(Platform, const LocalPlatform());
       appContext.setVariable(Logger, notifyingLogger);
-      appContext.setVariable(Doctor, new Doctor());
-      if (platform.isMacOS)
-        appContext.setVariable(Xcode, new Xcode());
-      appContext.setVariable(DeviceManager, new MockDeviceManager());
     });
 
     tearDown(() {
@@ -46,7 +34,7 @@
       notifyingLogger.dispose();
     });
 
-    _testUsingContext('daemon.version', () async {
+    testUsingContext('daemon.version command should succeed', () async {
       final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
       final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
       daemon = new Daemon(
@@ -55,7 +43,7 @@
         notifyingLogger: notifyingLogger
       );
       commands.add(<String, dynamic>{'id': 0, 'method': 'daemon.version'});
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['result'], isNotEmpty);
       expect(response['result'] is String, true);
@@ -63,7 +51,7 @@
       commands.close();
     });
 
-    _testUsingContext('daemon.logMessage', () {
+    testUsingContext('printError should send daemon.logMessage event', () {
       return appContext.runInZone(() async {
         final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
         final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
@@ -73,9 +61,9 @@
           notifyingLogger: notifyingLogger
         );
         printError('daemon.logMessage test');
-        final Map<String, dynamic> response = await responses.stream.where((Map<String, dynamic> map) {
+        final Map<String, dynamic> response = await responses.stream.firstWhere((Map<String, dynamic> map) {
           return map['event'] == 'daemon.logMessage' && map['params']['level'] == 'error';
-        }).first;
+        });
         expect(response['id'], isNull);
         expect(response['event'], 'daemon.logMessage');
         final Map<String, String> logMessage = response['params'];
@@ -86,7 +74,7 @@
       });
     });
 
-    _testUsingContext('daemon.logMessage logToStdout', () async {
+    testUsingContext('printStatus should log to stdout when logToStdout is enabled', () async {
       final StringBuffer buffer = new StringBuffer();
 
       await runZoned(() async {
@@ -110,7 +98,7 @@
       expect(buffer.toString().trim(), 'daemon.logMessage test');
     });
 
-    _testUsingContext('daemon.shutdown', () async {
+    testUsingContext('daemon.shutdown command should stop daemon', () async {
       final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
       final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
       daemon = new Daemon(
@@ -126,7 +114,7 @@
       });
     });
 
-    _testUsingContext('daemon.start', () async {
+    testUsingContext('app.start without a deviceId should report an error', () async {
       final DaemonCommand command = new DaemonCommand();
       applyMocksToCommand(command);
 
@@ -140,14 +128,14 @@
       );
 
       commands.add(<String, dynamic>{ 'id': 0, 'method': 'app.start' });
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['error'], contains('deviceId is required'));
       responses.close();
       commands.close();
     });
 
-    _testUsingContext('daemon.restart', () async {
+    testUsingContext('app.restart without an appId should report an error', () async {
       final DaemonCommand command = new DaemonCommand();
       applyMocksToCommand(command);
 
@@ -161,14 +149,14 @@
       );
 
       commands.add(<String, dynamic>{ 'id': 0, 'method': 'app.restart' });
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['error'], contains('appId is required'));
       responses.close();
       commands.close();
     });
 
-    _testUsingContext('daemon.callServiceExtension', () async {
+    testUsingContext('ext.flutter.debugPaint via service extension without an appId should report an error', () async {
       final DaemonCommand command = new DaemonCommand();
       applyMocksToCommand(command);
 
@@ -188,14 +176,14 @@
           'methodName': 'ext.flutter.debugPaint'
         }
       });
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['error'], contains('appId is required'));
       responses.close();
       commands.close();
     });
 
-    _testUsingContext('daemon.stop', () async {
+    testUsingContext('app.stop without appId should report an error', () async {
       final DaemonCommand command = new DaemonCommand();
       applyMocksToCommand(command);
 
@@ -209,14 +197,32 @@
       );
 
       commands.add(<String, dynamic>{ 'id': 0, 'method': 'app.stop' });
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['error'], contains('appId is required'));
       responses.close();
       commands.close();
     });
 
-    _testUsingContext('device.getDevices', () async {
+    testUsingContext('daemon should send showMessage on startup if no Android devices are available', () async {
+      final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
+      final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
+      daemon = new Daemon(
+          commands.stream,
+          responses.add,
+          notifyingLogger: notifyingLogger,
+      );
+
+      final Map<String, dynamic> response = await responses.stream.first;
+      expect(response['event'], 'daemon.showMessage');
+      expect(response['params'], isMap);
+      expect(response['params'], containsPair('title', 'Unable to list devices'));
+      expect(response['params'], containsPair('message', contains('Unable to discover Android devices')));
+    }, overrides: <Type, Generator>{
+      Doctor: () => new MockDoctor(androidCanListDevices: false),
+    });
+
+    testUsingContext('device.getDevices should respond with list', () async {
       final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
       final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
       daemon = new Daemon(
@@ -225,14 +231,14 @@
         notifyingLogger: notifyingLogger
       );
       commands.add(<String, dynamic>{'id': 0, 'method': 'device.getDevices'});
-      final Map<String, dynamic> response = await responses.stream.where(_notEvent).first;
+      final Map<String, dynamic> response = await responses.stream.firstWhere(_notEvent);
       expect(response['id'], 0);
       expect(response['result'], isList);
       responses.close();
       commands.close();
     });
 
-    _testUsingContext('device.notify', () {
+    testUsingContext('should send device.added event when device is discovered', () {
       final StreamController<Map<String, dynamic>> commands = new StreamController<Map<String, dynamic>>();
       final StreamController<Map<String, dynamic>> responses = new StreamController<Map<String, dynamic>>();
       daemon = new Daemon(
@@ -255,8 +261,37 @@
         responses.close();
         commands.close();
       });
+    }, overrides: <Type, Generator>{
+      Doctor: () => new MockDoctor(),
     });
   });
 }
 
 bool _notEvent(Map<String, dynamic> map) => map['event'] == null;
+
+class MockDoctor extends Doctor {
+  final bool androidCanListDevices;
+  final bool iosCanListDevices;
+
+  MockDoctor({this.androidCanListDevices: true, this.iosCanListDevices: true});
+
+  @override
+  AndroidWorkflow get androidWorkflow => new MockAndroidWorkflow(androidCanListDevices);
+
+  @override
+  IOSWorkflow get iosWorkflow => new MockIosWorkflow(iosCanListDevices);
+}
+
+class MockAndroidWorkflow extends AndroidWorkflow {
+  @override
+  final bool canListDevices;
+
+  MockAndroidWorkflow(this.canListDevices);
+}
+
+class MockIosWorkflow extends IOSWorkflow {
+  @override
+  final bool canListDevices;
+
+  MockIosWorkflow(this.canListDevices);
+}
diff --git a/packages/flutter_tools/test/src/mocks.dart b/packages/flutter_tools/test/src/mocks.dart
index c6e1af1..1ac69c9 100644
--- a/packages/flutter_tools/test/src/mocks.dart
+++ b/packages/flutter_tools/test/src/mocks.dart
@@ -42,6 +42,9 @@
   @override
   bool get supportsPlatform => true;
 
+  @override
+  bool get canListAnything => true;
+
   void addDevice(MockAndroidDevice device) {
     _devices.add(device);