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);
});