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

diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart
index 8c63b66..1e7f0db 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 2e6ddc4..6147d96 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -790,18 +790,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 cef0855..6c19930 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -82,6 +82,7 @@
       platform: globals.platform,
       xcdevice: globals.xcdevice,
       iosWorkflow: globals.iosWorkflow,
+      logger: globals.logger,
     ),
     IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils),
     FuchsiaDevices(
@@ -289,14 +290,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>();
       // Make initial population the default, fast polling timeout.
       _timer = _initTimer(null);
     }
@@ -306,7 +311,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.');
       }
@@ -315,7 +320,7 @@
     });
   }
 
-  void stopPolling() {
+  Future<void> stopPolling() async {
     _timer?.cancel();
     _timer = null;
   }
@@ -327,23 +332,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 51e354d..2d052cd 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/logger.dart';
 import '../base/platform.dart';
 import '../base/process.dart';
+import '../base/utils.dart';
 import '../build_info.dart';
 import '../convert.dart';
 import '../device.dart';
@@ -35,14 +36,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;
@@ -50,6 +59,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 ceb0eb5..191fff8 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>> getAvailableIOSDevices({ 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/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart
index bad20c2..4ddc184 100644
--- a/packages/flutter_tools/test/general.shard/device_test.dart
+++ b/packages/flutter_tools/test/general.shard/device_test.dart
@@ -66,9 +66,9 @@
 
   group('PollingDeviceDiscovery', () {
     testUsingContext('startPolling', () async {
-      FakeAsync().run((FakeAsync time) {
+      FakeAsync().run((FakeAsync time) async {
         final FakePollingDeviceDiscovery pollingDeviceDiscovery = FakePollingDeviceDiscovery();
-        pollingDeviceDiscovery.startPolling();
+        await pollingDeviceDiscovery.startPolling();
         time.elapse(const Duration(milliseconds: 4001));
         time.flushMicrotasks();
         // First check should use the default polling timeout
@@ -79,7 +79,7 @@
         time.flushMicrotasks();
         // Subsequent polling should be much longer.
         expect(pollingDeviceDiscovery.lastPollingTimeout, const Duration(seconds: 30));
-        pollingDeviceDiscovery.stopPolling();
+        await pollingDeviceDiscovery.stopPolling();
       });
     });
   });
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 ba4d78d..4d39209 100644
--- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -262,15 +262,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();
@@ -292,33 +294,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',
@@ -331,22 +308,185 @@
         fileSystem: MemoryFileSystem.test(),
         interfaceType: IOSDeviceInterface.usb,
       );
+
+      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(),
+        interfaceType: IOSDeviceInterface.usb,
+      );
+    });
+
+    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.getAvailableIOSDevices())
-          .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.getAvailableIOSDevices()).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.getAvailableIOSDevices())
+        .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.getAvailableIOSDevices()).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.getAvailableIOSDevices())
+          .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];
@@ -356,6 +496,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.');
@@ -367,6 +508,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 b60f9a2..afbaa64 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/io.dart' show ProcessException, ProcessResult;
@@ -19,7 +21,7 @@
 import '../../src/context.dart';
 
 void main() {
-  Logger logger;
+  BufferLogger logger;
 
   setUp(() {
     logger = BufferLogger.test();
@@ -353,6 +355,59 @@
         });
       });
 
+      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 {
+          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
+          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');