Skip to content

Commit

Permalink
Change iOS device discovery from polling to long-running observation …
Browse files Browse the repository at this point in the history
…(#59695)
  • Loading branch information
jmagman committed Jun 17, 2020
1 parent b041144 commit 1ad9baa
Show file tree
Hide file tree
Showing 8 changed files with 458 additions and 49 deletions.
6 changes: 6 additions & 0 deletions packages/flutter_tools/lib/src/base/utils.dart
Expand Up @@ -104,6 +104,12 @@ class ItemListNotifier<T> {
removedItems.forEach(_removedController.add);
}

void removeItem(T item) {
if (_items.remove(item)) {
_removedController.add(item);
}
}

/// Close the streams.
void dispose() {
_addedController.close();
Expand Down
12 changes: 7 additions & 5 deletions packages/flutter_tools/lib/src/commands/daemon.dart
Expand Up @@ -789,18 +789,20 @@ class DeviceDomain extends Domain {

/// 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.
Expand Down
29 changes: 17 additions & 12 deletions packages/flutter_tools/lib/src/device.dart
Expand Up @@ -80,6 +80,7 @@ class DeviceManager {
platform: globals.platform,
xcdevice: globals.xcdevice,
iosWorkflow: globals.iosWorkflow,
logger: globals.logger,
),
IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils),
FuchsiaDevices(),
Expand Down Expand Up @@ -277,14 +278,18 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
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();
}
}
Expand All @@ -293,15 +298,15 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
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.');
}
_timer = _initTimer();
});
}

void stopPolling() {
Future<void> stopPolling() async {
_timer?.cancel();
_timer = null;
}
Expand All @@ -313,23 +318,23 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {

@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();
Expand Down
63 changes: 63 additions & 0 deletions packages/flutter_tools/lib/src/ios/devices.dart
Expand Up @@ -16,6 +16,7 @@ import '../base/file_system.dart';
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';
Expand All @@ -36,21 +37,83 @@ class IOSDevices extends PollingDeviceDiscovery {
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;

@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) {
Expand Down
121 changes: 119 additions & 2 deletions packages/flutter_tools/lib/src/macos/xcode.dart
Expand Up @@ -194,6 +194,11 @@ class Xcode {
}
}

enum XCDeviceEvent {
attach,
detach,
}

/// A utility class for interacting with Xcode xcdevice command line tools.
class XCDevice {
XCDevice({
Expand All @@ -218,14 +223,34 @@ class XCDevice {
platform: platform,
processManager: processManager,
),
_xcode = xcode;
_xcode = xcode {

_setupDeviceIdentifierByEventStream();
}

void dispose() {
_deviceObservationProcess?.kill();
}

final ProcessUtils _processUtils;
final Logger _logger;
final IMobileDevice _iMobileDevice;
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;
Expand Down Expand Up @@ -287,7 +312,99 @@ class XCDevice {
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 {
Expand Down
14 changes: 10 additions & 4 deletions packages/flutter_tools/test/general.shard/base_utils_test.dart
Expand Up @@ -18,19 +18,25 @@ void main() {
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');
});
});
}

0 comments on commit 1ad9baa

Please sign in to comment.