Change iOS device discovery from polling to long-running observation (#59695)

diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart
index 30b3f7b..7ab7b26 100644
--- a/packages/flutter_tools/lib/src/base/utils.dart
+++ b/packages/flutter_tools/lib/src/base/utils.dart
@@ -104,6 +104,12 @@
     removedItems.forEach(_removedController.add);
   }
 
+  void removeItem(T item) {
+    if (_items.remove(item)) {
+      _removedController.add(item);
+    }
+  }
+
   /// Close the streams.
   void dispose() {
     _addedController.close();
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 32dd302..861cd51 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -789,18 +789,20 @@
 
   /// Enable device events.
   Future<void> enable(Map<String, dynamic> args) {
+    final List<Future<void>> calls = <Future<void>>[];
     for (final PollingDeviceDiscovery discoverer in _discoverers) {
-      discoverer.startPolling();
+      calls.add(discoverer.startPolling());
     }
-    return Future<void>.value();
+    return Future.wait<void>(calls);
   }
 
   /// Disable device events.
-  Future<void> disable(Map<String, dynamic> args) {
+  Future<void> disable(Map<String, dynamic> args) async {
+    final List<Future<void>> calls = <Future<void>>[];
     for (final PollingDeviceDiscovery discoverer in _discoverers) {
-      discoverer.stopPolling();
+      calls.add(discoverer.stopPolling());
     }
-    return Future<void>.value();
+    return Future.wait<void>(calls);
   }
 
   /// Forward a host port to a device port.
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index 9ccb5a2..37b0724 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -80,6 +80,7 @@
       platform: globals.platform,
       xcdevice: globals.xcdevice,
       iosWorkflow: globals.iosWorkflow,
+      logger: globals.logger,
     ),
     IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils),
     FuchsiaDevices(),
@@ -277,14 +278,18 @@
   static const Duration _pollingTimeout = Duration(seconds: 30);
 
   final String name;
-  ItemListNotifier<Device> _items;
+
+  @protected
+  @visibleForTesting
+  ItemListNotifier<Device> deviceNotifier;
+
   Timer _timer;
 
   Future<List<Device>> pollingGetDevices({ Duration timeout });
 
-  void startPolling() {
+  Future<void> startPolling() async {
     if (_timer == null) {
-      _items ??= ItemListNotifier<Device>();
+      deviceNotifier ??= ItemListNotifier<Device>();
       _timer = _initTimer();
     }
   }
@@ -293,7 +298,7 @@
     return Timer(_pollingInterval, () async {
       try {
         final List<Device> devices = await pollingGetDevices(timeout: _pollingTimeout);
-        _items.updateWithNewList(devices);
+        deviceNotifier.updateWithNewList(devices);
       } on TimeoutException {
         globals.printTrace('Device poll timed out. Will retry.');
       }
@@ -301,7 +306,7 @@
     });
   }
 
-  void stopPolling() {
+  Future<void> stopPolling() async {
     _timer?.cancel();
     _timer = null;
   }
@@ -313,23 +318,23 @@
 
   @override
   Future<List<Device>> discoverDevices({ Duration timeout }) async {
-    _items = null;
+    deviceNotifier = null;
     return _populateDevices(timeout: timeout);
   }
 
   Future<List<Device>> _populateDevices({ Duration timeout }) async {
-    _items ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout));
-    return _items.items;
+    deviceNotifier ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout));
+    return deviceNotifier.items;
   }
 
   Stream<Device> get onAdded {
-    _items ??= ItemListNotifier<Device>();
-    return _items.onAdded;
+    deviceNotifier ??= ItemListNotifier<Device>();
+    return deviceNotifier.onAdded;
   }
 
   Stream<Device> get onRemoved {
-    _items ??= ItemListNotifier<Device>();
-    return _items.onRemoved;
+    deviceNotifier ??= ItemListNotifier<Device>();
+    return deviceNotifier.onRemoved;
   }
 
   void dispose() => stopPolling();
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index faf1218..d23fd6c 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -16,6 +16,7 @@
 import '../base/io.dart';
 import '../base/logger.dart';
 import '../base/process.dart';
+import '../base/utils.dart';
 import '../build_info.dart';
 import '../convert.dart';
 import '../device.dart';
@@ -36,14 +37,22 @@
     Platform platform,
     XCDevice xcdevice,
     IOSWorkflow iosWorkflow,
+    Logger logger,
   }) : _platform = platform ?? globals.platform,
        _xcdevice = xcdevice ?? globals.xcdevice,
        _iosWorkflow = iosWorkflow ?? globals.iosWorkflow,
+       _logger = logger ?? globals.logger,
        super('iOS devices');
 
+  @override
+  void dispose() {
+    _observedDeviceEventsSubscription?.cancel();
+  }
+
   final Platform _platform;
   final XCDevice _xcdevice;
   final IOSWorkflow _iosWorkflow;
+  final Logger _logger;
 
   @override
   bool get supportsPlatform => _platform.isMacOS;
@@ -51,6 +60,60 @@
   @override
   bool get canListAnything => _iosWorkflow.canListDevices;
 
+  StreamSubscription<Map<XCDeviceEvent, String>> _observedDeviceEventsSubscription;
+
+  @override
+  Future<void> startPolling() async {
+    if (!_platform.isMacOS) {
+      throw UnsupportedError(
+        'Control of iOS devices or simulators only supported on macOS.'
+      );
+    }
+
+    deviceNotifier ??= ItemListNotifier<Device>();
+
+    // Start by populating all currently attached devices.
+    deviceNotifier.updateWithNewList(await pollingGetDevices());
+
+    // cancel any outstanding subscriptions.
+    await _observedDeviceEventsSubscription?.cancel();
+    _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents().listen(
+      _onDeviceEvent,
+      onError: (dynamic error, StackTrace stack) {
+        _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack');
+      }, onDone: () {
+        // If xcdevice is killed or otherwise dies, polling will be stopped.
+        // No retry is attempted and the polling client will have to restart polling
+        // (restart the IDE). Avoid hammering on a process that is
+        // continuously failing.
+        _logger.printTrace('xcdevice observe stopped');
+      },
+      cancelOnError: true,
+    );
+  }
+
+  Future<void> _onDeviceEvent(Map<XCDeviceEvent, String> event) async {
+    final XCDeviceEvent eventType = event.containsKey(XCDeviceEvent.attach) ? XCDeviceEvent.attach : XCDeviceEvent.detach;
+    final String deviceIdentifier = event[eventType];
+    final Device knownDevice = deviceNotifier.items
+      .firstWhere((Device device) => device.id == deviceIdentifier, orElse: () => null);
+
+    // Ignore already discovered devices (maybe populated at the beginning).
+    if (eventType == XCDeviceEvent.attach && knownDevice == null) {
+      // There's no way to get details for an individual attached device,
+      // so repopulate them all.
+      final List<Device> devices = await pollingGetDevices();
+      deviceNotifier.updateWithNewList(devices);
+    } else if (eventType == XCDeviceEvent.detach && knownDevice != null) {
+      deviceNotifier.removeItem(knownDevice);
+    }
+  }
+
+  @override
+  Future<void> stopPolling() async {
+    await _observedDeviceEventsSubscription?.cancel();
+  }
+
   @override
   Future<List<Device>> pollingGetDevices({ Duration timeout }) async {
     if (!_platform.isMacOS) {
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
index f259e0a..36d4d5d 100644
--- a/packages/flutter_tools/lib/src/macos/xcode.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -194,6 +194,11 @@
   }
 }
 
+enum XCDeviceEvent {
+  attach,
+  detach,
+}
+
 /// A utility class for interacting with Xcode xcdevice command line tools.
 class XCDevice {
   XCDevice({
@@ -218,7 +223,14 @@
         platform: platform,
         processManager: processManager,
       ),
-      _xcode = xcode;
+      _xcode = xcode {
+
+    _setupDeviceIdentifierByEventStream();
+  }
+
+  void dispose() {
+    _deviceObservationProcess?.kill();
+  }
 
   final ProcessUtils _processUtils;
   final Logger _logger;
@@ -226,6 +238,19 @@
   final IOSDeploy _iosDeploy;
   final Xcode _xcode;
 
+  List<dynamic> _cachedListResults;
+  Process _deviceObservationProcess;
+  StreamController<Map<XCDeviceEvent, String>> _deviceIdentifierByEvent;
+
+  void _setupDeviceIdentifierByEventStream() {
+    // _deviceIdentifierByEvent Should always be available for listeners
+    // in case polling needs to be stopped and restarted.
+    _deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast(
+      onListen: _startObservingTetheredIOSDevices,
+      onCancel: _stopObservingTetheredIOSDevices,
+    );
+  }
+
   bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck && xcdevicePath != null;
 
   String _xcdevicePath;
@@ -287,7 +312,99 @@
     return null;
   }
 
-  List<dynamic> _cachedListResults;
+  /// Observe identifiers (UDIDs) of devices as they attach and detach.
+  ///
+  /// Each attach and detach event is a tuple of one event type
+  /// and identifier.
+  Stream<Map<XCDeviceEvent, String>> observedDeviceEvents() {
+    if (!isInstalled) {
+      _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
+      return null;
+    }
+    return _deviceIdentifierByEvent.stream;
+  }
+
+  // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+  // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+  final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): (\w*)$');
+
+  Future<void> _startObservingTetheredIOSDevices() async {
+    try {
+      if (_deviceObservationProcess != null) {
+        throw Exception('xcdevice observe restart failed');
+      }
+
+      // Run in interactive mode (via script) to convince
+      // xcdevice it has a terminal attached in order to redirect stdout.
+      _deviceObservationProcess = await _processUtils.start(
+        <String>[
+          'script',
+          '-t',
+          '0',
+          '/dev/null',
+          'xcrun',
+          'xcdevice',
+          'observe',
+          '--both',
+        ],
+      );
+
+      final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess.stdout
+        .transform<String>(utf8.decoder)
+        .transform<String>(const LineSplitter())
+        .listen((String line) {
+
+        // xcdevice observe example output of UDIDs:
+        //
+        // Listening for all devices, on both interfaces.
+        // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+        // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+        // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+        final RegExpMatch match = _observationIdentifierPattern.firstMatch(line);
+        if (match != null && match.groupCount == 2) {
+          final String verb = match.group(1).toLowerCase();
+          final String identifier = match.group(2);
+          if (verb.startsWith('attach')) {
+            _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
+              XCDeviceEvent.attach: identifier
+            });
+          } else if (verb.startsWith('detach')) {
+            _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
+              XCDeviceEvent.detach: identifier
+            });
+          }
+        }
+      });
+      final StreamSubscription<String> stderrSubscription = _deviceObservationProcess.stderr
+        .transform<String>(utf8.decoder)
+        .transform<String>(const LineSplitter())
+        .listen((String line) {
+        _logger.printTrace('xcdevice observe error: $line');
+      });
+      unawaited(_deviceObservationProcess.exitCode.then((int status) {
+        _logger.printTrace('xcdevice exited with code $exitCode');
+        unawaited(stdoutSubscription.cancel());
+        unawaited(stderrSubscription.cancel());
+      }).whenComplete(() async {
+        if (_deviceIdentifierByEvent.hasListener) {
+          // Tell listeners the process died.
+          await _deviceIdentifierByEvent.close();
+        }
+        _deviceObservationProcess = null;
+
+        // Reopen it so new listeners can resume polling.
+        _setupDeviceIdentifierByEventStream();
+      }));
+    } on ProcessException catch (exception, stackTrace) {
+      _deviceIdentifierByEvent.addError(exception, stackTrace);
+    } on ArgumentError catch (exception, stackTrace) {
+      _deviceIdentifierByEvent.addError(exception, stackTrace);
+    }
+  }
+
+  void _stopObservingTetheredIOSDevices() {
+    _deviceObservationProcess?.kill();
+  }
 
   /// [timeout] defaults to 2 seconds.
   Future<List<IOSDevice>> getAvailableTetheredIOSDevices({ Duration timeout }) async {
diff --git a/packages/flutter_tools/test/general.shard/base_utils_test.dart b/packages/flutter_tools/test/general.shard/base_utils_test.dart
index 2a9e605..ccd7533 100644
--- a/packages/flutter_tools/test/general.shard/base_utils_test.dart
+++ b/packages/flutter_tools/test/general.shard/base_utils_test.dart
@@ -18,19 +18,25 @@
       final Future<List<String>> removedStreamItems = list.onRemoved.toList();
 
       list.updateWithNewList(<String>['aaa']);
-      list.updateWithNewList(<String>['aaa', 'bbb']);
-      list.updateWithNewList(<String>['bbb']);
+      list.removeItem('bogus');
+      list.updateWithNewList(<String>['aaa', 'bbb', 'ccc']);
+      list.updateWithNewList(<String>['bbb', 'ccc']);
+      list.removeItem('bbb');
+
+      expect(list.items, <String>['ccc']);
       list.dispose();
 
       final List<String> addedItems = await addedStreamItems;
       final List<String> removedItems = await removedStreamItems;
 
-      expect(addedItems.length, 2);
+      expect(addedItems.length, 3);
       expect(addedItems.first, 'aaa');
       expect(addedItems[1], 'bbb');
+      expect(addedItems[2], 'ccc');
 
-      expect(removedItems.length, 1);
+      expect(removedItems.length, 2);
       expect(removedItems.first, 'aaa');
+      expect(removedItems[1], 'bbb');
     });
   });
 }
diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
index a5fa58d..04c5039 100644
--- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -258,15 +258,17 @@
     });
   });
 
-  group('pollingGetDevices', () {
+  group('polling', () {
     MockXcdevice mockXcdevice;
     MockArtifacts mockArtifacts;
     MockCache mockCache;
     FakeProcessManager fakeProcessManager;
-    Logger logger;
+    BufferLogger logger;
     IOSDeploy iosDeploy;
     IMobileDevice iMobileDevice;
     IOSWorkflow mockIosWorkflow;
+    IOSDevice device1;
+    IOSDevice device2;
 
     setUp(() {
       mockXcdevice = MockXcdevice();
@@ -288,33 +290,8 @@
         processManager: fakeProcessManager,
         logger: logger,
       );
-    });
 
-    final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform];
-    for (final Platform unsupportedPlatform in unsupportedPlatforms) {
-      testWithoutContext('throws Unsupported Operation exception on ${unsupportedPlatform.operatingSystem}', () async {
-        final IOSDevices iosDevices = IOSDevices(
-          platform: unsupportedPlatform,
-          xcdevice: mockXcdevice,
-          iosWorkflow: mockIosWorkflow,
-        );
-        when(mockXcdevice.isInstalled).thenReturn(false);
-        expect(
-            () async { await iosDevices.pollingGetDevices(); },
-            throwsA(isA<UnsupportedError>()),
-        );
-      });
-    }
-
-    testWithoutContext('returns attached devices', () async {
-      final IOSDevices iosDevices = IOSDevices(
-        platform: macPlatform,
-        xcdevice: mockXcdevice,
-        iosWorkflow: mockIosWorkflow,
-      );
-      when(mockXcdevice.isInstalled).thenReturn(true);
-
-      final IOSDevice device = IOSDevice(
+      device1 = IOSDevice(
         'd83d5bc53967baa0ee18626ba87b6254b2ab5418',
         name: 'Paired iPhone',
         sdkVersion: '13.3',
@@ -326,22 +303,184 @@
         platform: macPlatform,
         fileSystem: MemoryFileSystem.test(),
       );
+
+      device2 = IOSDevice(
+        '43ad2fda7991b34fe1acbda82f9e2fd3d6ddc9f7',
+        name: 'iPhone 6s',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64,
+        artifacts: mockArtifacts,
+        iosDeploy: iosDeploy,
+        iMobileDevice: iMobileDevice,
+        logger: logger,
+        platform: macPlatform,
+        fileSystem: MemoryFileSystem.test(),
+      );
+    });
+
+    testWithoutContext('start polling', () async {
+      final IOSDevices iosDevices = IOSDevices(
+        platform: macPlatform,
+        xcdevice: mockXcdevice,
+        iosWorkflow: mockIosWorkflow,
+        logger: logger,
+      );
+      when(mockXcdevice.isInstalled).thenReturn(true);
+
+      int fetchDevicesCount = 0;
       when(mockXcdevice.getAvailableTetheredIOSDevices())
-          .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device]));
+        .thenAnswer((Invocation invocation) {
+          if (fetchDevicesCount == 0) {
+            // Initial time, no devices.
+            fetchDevicesCount++;
+            return Future<List<IOSDevice>>.value(<IOSDevice>[]);
+          } else if (fetchDevicesCount == 1) {
+            // Simulate 2 devices added later.
+            fetchDevicesCount++;
+            return Future<List<IOSDevice>>.value(<IOSDevice>[device1, device2]);
+          }
+          fail('Too many calls to getAvailableTetheredIOSDevices');
+      });
+
+      int addedCount = 0;
+      final Completer<void> added = Completer<void>();
+      iosDevices.onAdded.listen((Device device) {
+        addedCount++;
+        // 2 devices will be added.
+        // Will throw over-completion if called more than twice.
+        if (addedCount >= 2) {
+          added.complete();
+        }
+      });
+
+      final Completer<void> removed = Completer<void>();
+      iosDevices.onRemoved.listen((Device device) {
+        // Will throw over-completion if called more than once.
+        removed.complete();
+      });
+
+      final StreamController<Map<XCDeviceEvent, String>> eventStream = StreamController<Map<XCDeviceEvent, String>>();
+      when(mockXcdevice.observedDeviceEvents()).thenAnswer((_) => eventStream.stream);
+
+      await iosDevices.startPolling();
+      verify(mockXcdevice.getAvailableTetheredIOSDevices()).called(1);
+
+      expect(iosDevices.deviceNotifier.items, isEmpty);
+      expect(eventStream.hasListener, isTrue);
+
+      eventStream.add(<XCDeviceEvent, String>{
+        XCDeviceEvent.attach: 'd83d5bc53967baa0ee18626ba87b6254b2ab5418'
+      });
+      await added.future;
+      expect(iosDevices.deviceNotifier.items.length, 2);
+      expect(iosDevices.deviceNotifier.items, contains(device1));
+      expect(iosDevices.deviceNotifier.items, contains(device2));
+
+      eventStream.add(<XCDeviceEvent, String>{
+        XCDeviceEvent.detach: 'd83d5bc53967baa0ee18626ba87b6254b2ab5418'
+      });
+      await removed.future;
+      expect(iosDevices.deviceNotifier.items, <Device>[device2]);
+
+      // Remove stream will throw over-completion if called more than once
+      // which proves this is ignored.
+      eventStream.add(<XCDeviceEvent, String>{
+        XCDeviceEvent.detach: 'bogus'
+      });
+
+      expect(addedCount, 2);
+
+      await iosDevices.stopPolling();
+
+      expect(eventStream.hasListener, isFalse);
+    });
+
+    testWithoutContext('polling can be restarted if stream is closed', () async {
+      final IOSDevices iosDevices = IOSDevices(
+        platform: macPlatform,
+        xcdevice: mockXcdevice,
+        iosWorkflow: mockIosWorkflow,
+        logger: logger,
+      );
+      when(mockXcdevice.isInstalled).thenReturn(true);
+
+      when(mockXcdevice.getAvailableTetheredIOSDevices())
+        .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[]));
+
+      final StreamController<Map<XCDeviceEvent, String>> eventStream = StreamController<Map<XCDeviceEvent, String>>();
+      final StreamController<Map<XCDeviceEvent, String>> rescheduledStream = StreamController<Map<XCDeviceEvent, String>>();
+
+      bool reschedule = false;
+      when(mockXcdevice.observedDeviceEvents()).thenAnswer((Invocation invocation) {
+        if (!reschedule) {
+          reschedule = true;
+          return eventStream.stream;
+        }
+        return rescheduledStream.stream;
+      });
+
+      await iosDevices.startPolling();
+      expect(eventStream.hasListener, isTrue);
+      verify(mockXcdevice.getAvailableTetheredIOSDevices()).called(1);
+
+      // Pretend xcdevice crashed.
+      await eventStream.close();
+      expect(logger.traceText, contains('xcdevice observe stopped'));
+
+      // Confirm a restart still gets streamed events.
+      await iosDevices.startPolling();
+
+      expect(eventStream.hasListener, isFalse);
+      expect(rescheduledStream.hasListener, isTrue);
+
+      await iosDevices.stopPolling();
+      expect(rescheduledStream.hasListener, isFalse);
+    });
+
+    final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform];
+    for (final Platform unsupportedPlatform in unsupportedPlatforms) {
+      testWithoutContext('pollingGetDevices throws Unsupported Operation exception on ${unsupportedPlatform.operatingSystem}', () async {
+        final IOSDevices iosDevices = IOSDevices(
+          platform: unsupportedPlatform,
+          xcdevice: mockXcdevice,
+          iosWorkflow: mockIosWorkflow,
+          logger: logger,
+        );
+        when(mockXcdevice.isInstalled).thenReturn(false);
+        expect(
+            () async { await iosDevices.pollingGetDevices(); },
+            throwsA(isA<UnsupportedError>()),
+        );
+      });
+    }
+
+    testWithoutContext('pollingGetDevices returns attached devices', () async {
+      final IOSDevices iosDevices = IOSDevices(
+        platform: macPlatform,
+        xcdevice: mockXcdevice,
+        iosWorkflow: mockIosWorkflow,
+        logger: logger,
+      );
+      when(mockXcdevice.isInstalled).thenReturn(true);
+
+      when(mockXcdevice.getAvailableTetheredIOSDevices())
+          .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device1]));
 
       final List<Device> devices = await iosDevices.pollingGetDevices();
       expect(devices, hasLength(1));
-      expect(identical(devices.first, device), isTrue);
+      expect(identical(devices.first, device1), isTrue);
     });
   });
 
   group('getDiagnostics', () {
     MockXcdevice mockXcdevice;
     IOSWorkflow mockIosWorkflow;
+    Logger logger;
 
     setUp(() {
       mockXcdevice = MockXcdevice();
       mockIosWorkflow = MockIOSWorkflow();
+      logger = BufferLogger.test();
     });
 
     final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform];
@@ -351,6 +490,7 @@
           platform: unsupportedPlatform,
           xcdevice: mockXcdevice,
           iosWorkflow: mockIosWorkflow,
+          logger: logger,
         );
         when(mockXcdevice.isInstalled).thenReturn(false);
         expect((await iosDevices.getDiagnostics()).first, 'Control of iOS devices or simulators only supported on macOS.');
@@ -362,6 +502,7 @@
         platform: macPlatform,
         xcdevice: mockXcdevice,
         iosWorkflow: mockIosWorkflow,
+        logger: logger,
       );
       when(mockXcdevice.isInstalled).thenReturn(true);
       when(mockXcdevice.getDiagnostics())
diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
index e070232..c1e99a3 100644
--- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/xcode_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/artifacts.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
@@ -21,7 +23,7 @@
 
 void main() {
   ProcessManager processManager;
-  Logger logger;
+  BufferLogger logger;
 
   setUp(() {
     logger = BufferLogger.test();
@@ -113,7 +115,7 @@
       when(platform.isMacOS).thenReturn(true);
       const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
       when(processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+          .thenReturn(ProcessResult(1, 0, xcodePath, ''));
       when(mockXcodeProjectInterpreter.isInstalled).thenReturn(false);
 
       expect(xcode.isInstalledAndMeetsVersionCheck, isFalse);
@@ -122,7 +124,7 @@
     testWithoutContext('isInstalledAndMeetsVersionCheck is false when no xcode-select', () {
       when(platform.isMacOS).thenReturn(true);
       when(processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-        .thenReturn(ProcessResult(1, 127, '', 'ERROR'));
+          .thenReturn(ProcessResult(1, 127, '', 'ERROR'));
       when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
       when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
       when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
@@ -134,7 +136,7 @@
       when(platform.isMacOS).thenReturn(true);
       const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
       when(processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+          .thenReturn(ProcessResult(1, 0, xcodePath, ''));
       when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
       when(mockXcodeProjectInterpreter.majorVersion).thenReturn(10);
       when(mockXcodeProjectInterpreter.minorVersion).thenReturn(2);
@@ -146,7 +148,7 @@
       when(platform.isMacOS).thenReturn(true);
       const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
       when(processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-        .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+          .thenReturn(ProcessResult(1, 0, xcodePath, ''));
       when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
       when(mockXcodeProjectInterpreter.majorVersion).thenReturn(11);
       when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
@@ -203,7 +205,7 @@
         Future<ProcessResult>.value(ProcessResult(1, 1, '', 'xcrun: error:')));
 
         expect(() async => await xcode.sdkLocation(SdkType.iPhone),
-          throwsToolExit(message: 'Could not find SDK location'));
+            throwsToolExit(message: 'Could not find SDK location'));
       });
     });
   });
@@ -274,6 +276,73 @@
 
         expect(await xcdevice.getAvailableTetheredIOSDevices(), isEmpty);
       });
+    });
+
+    group('observe device events', () {
+      testWithoutContext('Xcode not installed', () async {
+        when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(false);
+
+        expect(xcdevice.observedDeviceEvents(), isNull);
+        expect(logger.traceText, contains("Xcode not found. Run 'flutter doctor' for more information."));
+      });
+
+      testUsingContext('relays events', () async {
+        final FakeProcessManager fakeProcessManager = FakeProcessManager.list(<FakeCommand>[]);
+        when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
+        fakeProcessManager.addCommand(const FakeCommand(
+          command: <String>['xcrun', '--find', 'xcdevice'],
+          stdout: '/path/to/xcdevice',
+        ));
+
+        fakeProcessManager.addCommand(const FakeCommand(
+          command: <String>[
+            'script',
+            '-t',
+            '0',
+            '/dev/null',
+            'xcrun',
+            'xcdevice',
+            'observe',
+            '--both',
+          ], stdout: 'Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418\n'
+            'Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418',
+          stderr: 'Some error',
+        ));
+
+        final Completer<void> attach = Completer<void>();
+        final Completer<void> detach = Completer<void>();
+
+        // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+        // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
+
+        final XCDevice xcdevice = XCDevice(
+          processManager: fakeProcessManager,
+          logger: logger,
+          xcode: mockXcode,
+          platform: null,
+          artifacts: mockArtifacts,
+          cache: mockCache,
+        );
+        xcdevice.observedDeviceEvents().listen((Map<XCDeviceEvent, String> event) {
+          expect(event.length, 1);
+          if (event.containsKey(XCDeviceEvent.attach)) {
+            expect(event[XCDeviceEvent.attach], 'd83d5bc53967baa0ee18626ba87b6254b2ab5418');
+            attach.complete();
+          } else if (event.containsKey(XCDeviceEvent.detach)) {
+            expect(event[XCDeviceEvent.detach], 'd83d5bc53967baa0ee18626ba87b6254b2ab5418');
+            detach.complete();
+          } else {
+            fail('Unexpected event');
+          }
+        });
+        await attach.future;
+        await detach.future;
+        expect(logger.traceText, contains('xcdevice observe error: Some error'));
+      });
+    });
+
+    group('available devices', () {
+      final FakePlatform macPlatform = FakePlatform(operatingSystem: 'macos');
 
       testUsingContext('returns devices', () async {
         when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
@@ -398,10 +467,10 @@
         when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
 
         when(processManager.runSync(<String>['xcrun', '--find', 'xcdevice']))
-          .thenReturn(ProcessResult(1, 0, '/path/to/xcdevice', ''));
+            .thenReturn(ProcessResult(1, 0, '/path/to/xcdevice', ''));
 
         when(processManager.run(any))
-          .thenAnswer((_) => Future<ProcessResult>.value(ProcessResult(1, 0, '[]', '')));
+            .thenAnswer((_) => Future<ProcessResult>.value(ProcessResult(1, 0, '[]', '')));
         await xcdevice.getAvailableTetheredIOSDevices(timeout: const Duration(seconds: 20));
         verify(processManager.run(<String>['xcrun', 'xcdevice', 'list', '--timeout', '20'])).called(1);
       });