Skip to content

Commit 1ad9baa

Browse files
authored
Change iOS device discovery from polling to long-running observation (flutter#59695)
1 parent b041144 commit 1ad9baa

File tree

8 files changed

+458
-49
lines changed

8 files changed

+458
-49
lines changed

packages/flutter_tools/lib/src/base/utils.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ class ItemListNotifier<T> {
104104
removedItems.forEach(_removedController.add);
105105
}
106106

107+
void removeItem(T item) {
108+
if (_items.remove(item)) {
109+
_removedController.add(item);
110+
}
111+
}
112+
107113
/// Close the streams.
108114
void dispose() {
109115
_addedController.close();

packages/flutter_tools/lib/src/commands/daemon.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -789,18 +789,20 @@ class DeviceDomain extends Domain {
789789

790790
/// Enable device events.
791791
Future<void> enable(Map<String, dynamic> args) {
792+
final List<Future<void>> calls = <Future<void>>[];
792793
for (final PollingDeviceDiscovery discoverer in _discoverers) {
793-
discoverer.startPolling();
794+
calls.add(discoverer.startPolling());
794795
}
795-
return Future<void>.value();
796+
return Future.wait<void>(calls);
796797
}
797798

798799
/// Disable device events.
799-
Future<void> disable(Map<String, dynamic> args) {
800+
Future<void> disable(Map<String, dynamic> args) async {
801+
final List<Future<void>> calls = <Future<void>>[];
800802
for (final PollingDeviceDiscovery discoverer in _discoverers) {
801-
discoverer.stopPolling();
803+
calls.add(discoverer.stopPolling());
802804
}
803-
return Future<void>.value();
805+
return Future.wait<void>(calls);
804806
}
805807

806808
/// Forward a host port to a device port.

packages/flutter_tools/lib/src/device.dart

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class DeviceManager {
8080
platform: globals.platform,
8181
xcdevice: globals.xcdevice,
8282
iosWorkflow: globals.iosWorkflow,
83+
logger: globals.logger,
8384
),
8485
IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils),
8586
FuchsiaDevices(),
@@ -277,14 +278,18 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
277278
static const Duration _pollingTimeout = Duration(seconds: 30);
278279

279280
final String name;
280-
ItemListNotifier<Device> _items;
281+
282+
@protected
283+
@visibleForTesting
284+
ItemListNotifier<Device> deviceNotifier;
285+
281286
Timer _timer;
282287

283288
Future<List<Device>> pollingGetDevices({ Duration timeout });
284289

285-
void startPolling() {
290+
Future<void> startPolling() async {
286291
if (_timer == null) {
287-
_items ??= ItemListNotifier<Device>();
292+
deviceNotifier ??= ItemListNotifier<Device>();
288293
_timer = _initTimer();
289294
}
290295
}
@@ -293,15 +298,15 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
293298
return Timer(_pollingInterval, () async {
294299
try {
295300
final List<Device> devices = await pollingGetDevices(timeout: _pollingTimeout);
296-
_items.updateWithNewList(devices);
301+
deviceNotifier.updateWithNewList(devices);
297302
} on TimeoutException {
298303
globals.printTrace('Device poll timed out. Will retry.');
299304
}
300305
_timer = _initTimer();
301306
});
302307
}
303308

304-
void stopPolling() {
309+
Future<void> stopPolling() async {
305310
_timer?.cancel();
306311
_timer = null;
307312
}
@@ -313,23 +318,23 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
313318

314319
@override
315320
Future<List<Device>> discoverDevices({ Duration timeout }) async {
316-
_items = null;
321+
deviceNotifier = null;
317322
return _populateDevices(timeout: timeout);
318323
}
319324

320325
Future<List<Device>> _populateDevices({ Duration timeout }) async {
321-
_items ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout));
322-
return _items.items;
326+
deviceNotifier ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout));
327+
return deviceNotifier.items;
323328
}
324329

325330
Stream<Device> get onAdded {
326-
_items ??= ItemListNotifier<Device>();
327-
return _items.onAdded;
331+
deviceNotifier ??= ItemListNotifier<Device>();
332+
return deviceNotifier.onAdded;
328333
}
329334

330335
Stream<Device> get onRemoved {
331-
_items ??= ItemListNotifier<Device>();
332-
return _items.onRemoved;
336+
deviceNotifier ??= ItemListNotifier<Device>();
337+
return deviceNotifier.onRemoved;
333338
}
334339

335340
void dispose() => stopPolling();

packages/flutter_tools/lib/src/ios/devices.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../base/file_system.dart';
1616
import '../base/io.dart';
1717
import '../base/logger.dart';
1818
import '../base/process.dart';
19+
import '../base/utils.dart';
1920
import '../build_info.dart';
2021
import '../convert.dart';
2122
import '../device.dart';
@@ -36,21 +37,83 @@ class IOSDevices extends PollingDeviceDiscovery {
3637
Platform platform,
3738
XCDevice xcdevice,
3839
IOSWorkflow iosWorkflow,
40+
Logger logger,
3941
}) : _platform = platform ?? globals.platform,
4042
_xcdevice = xcdevice ?? globals.xcdevice,
4143
_iosWorkflow = iosWorkflow ?? globals.iosWorkflow,
44+
_logger = logger ?? globals.logger,
4245
super('iOS devices');
4346

47+
@override
48+
void dispose() {
49+
_observedDeviceEventsSubscription?.cancel();
50+
}
51+
4452
final Platform _platform;
4553
final XCDevice _xcdevice;
4654
final IOSWorkflow _iosWorkflow;
55+
final Logger _logger;
4756

4857
@override
4958
bool get supportsPlatform => _platform.isMacOS;
5059

5160
@override
5261
bool get canListAnything => _iosWorkflow.canListDevices;
5362

63+
StreamSubscription<Map<XCDeviceEvent, String>> _observedDeviceEventsSubscription;
64+
65+
@override
66+
Future<void> startPolling() async {
67+
if (!_platform.isMacOS) {
68+
throw UnsupportedError(
69+
'Control of iOS devices or simulators only supported on macOS.'
70+
);
71+
}
72+
73+
deviceNotifier ??= ItemListNotifier<Device>();
74+
75+
// Start by populating all currently attached devices.
76+
deviceNotifier.updateWithNewList(await pollingGetDevices());
77+
78+
// cancel any outstanding subscriptions.
79+
await _observedDeviceEventsSubscription?.cancel();
80+
_observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents().listen(
81+
_onDeviceEvent,
82+
onError: (dynamic error, StackTrace stack) {
83+
_logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack');
84+
}, onDone: () {
85+
// If xcdevice is killed or otherwise dies, polling will be stopped.
86+
// No retry is attempted and the polling client will have to restart polling
87+
// (restart the IDE). Avoid hammering on a process that is
88+
// continuously failing.
89+
_logger.printTrace('xcdevice observe stopped');
90+
},
91+
cancelOnError: true,
92+
);
93+
}
94+
95+
Future<void> _onDeviceEvent(Map<XCDeviceEvent, String> event) async {
96+
final XCDeviceEvent eventType = event.containsKey(XCDeviceEvent.attach) ? XCDeviceEvent.attach : XCDeviceEvent.detach;
97+
final String deviceIdentifier = event[eventType];
98+
final Device knownDevice = deviceNotifier.items
99+
.firstWhere((Device device) => device.id == deviceIdentifier, orElse: () => null);
100+
101+
// Ignore already discovered devices (maybe populated at the beginning).
102+
if (eventType == XCDeviceEvent.attach && knownDevice == null) {
103+
// There's no way to get details for an individual attached device,
104+
// so repopulate them all.
105+
final List<Device> devices = await pollingGetDevices();
106+
deviceNotifier.updateWithNewList(devices);
107+
} else if (eventType == XCDeviceEvent.detach && knownDevice != null) {
108+
deviceNotifier.removeItem(knownDevice);
109+
}
110+
}
111+
112+
@override
113+
Future<void> stopPolling() async {
114+
await _observedDeviceEventsSubscription?.cancel();
115+
}
116+
54117
@override
55118
Future<List<Device>> pollingGetDevices({ Duration timeout }) async {
56119
if (!_platform.isMacOS) {

packages/flutter_tools/lib/src/macos/xcode.dart

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ class Xcode {
194194
}
195195
}
196196

197+
enum XCDeviceEvent {
198+
attach,
199+
detach,
200+
}
201+
197202
/// A utility class for interacting with Xcode xcdevice command line tools.
198203
class XCDevice {
199204
XCDevice({
@@ -218,14 +223,34 @@ class XCDevice {
218223
platform: platform,
219224
processManager: processManager,
220225
),
221-
_xcode = xcode;
226+
_xcode = xcode {
227+
228+
_setupDeviceIdentifierByEventStream();
229+
}
230+
231+
void dispose() {
232+
_deviceObservationProcess?.kill();
233+
}
222234

223235
final ProcessUtils _processUtils;
224236
final Logger _logger;
225237
final IMobileDevice _iMobileDevice;
226238
final IOSDeploy _iosDeploy;
227239
final Xcode _xcode;
228240

241+
List<dynamic> _cachedListResults;
242+
Process _deviceObservationProcess;
243+
StreamController<Map<XCDeviceEvent, String>> _deviceIdentifierByEvent;
244+
245+
void _setupDeviceIdentifierByEventStream() {
246+
// _deviceIdentifierByEvent Should always be available for listeners
247+
// in case polling needs to be stopped and restarted.
248+
_deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast(
249+
onListen: _startObservingTetheredIOSDevices,
250+
onCancel: _stopObservingTetheredIOSDevices,
251+
);
252+
}
253+
229254
bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck && xcdevicePath != null;
230255

231256
String _xcdevicePath;
@@ -287,7 +312,99 @@ class XCDevice {
287312
return null;
288313
}
289314

290-
List<dynamic> _cachedListResults;
315+
/// Observe identifiers (UDIDs) of devices as they attach and detach.
316+
///
317+
/// Each attach and detach event is a tuple of one event type
318+
/// and identifier.
319+
Stream<Map<XCDeviceEvent, String>> observedDeviceEvents() {
320+
if (!isInstalled) {
321+
_logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
322+
return null;
323+
}
324+
return _deviceIdentifierByEvent.stream;
325+
}
326+
327+
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
328+
// Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
329+
final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): (\w*)$');
330+
331+
Future<void> _startObservingTetheredIOSDevices() async {
332+
try {
333+
if (_deviceObservationProcess != null) {
334+
throw Exception('xcdevice observe restart failed');
335+
}
336+
337+
// Run in interactive mode (via script) to convince
338+
// xcdevice it has a terminal attached in order to redirect stdout.
339+
_deviceObservationProcess = await _processUtils.start(
340+
<String>[
341+
'script',
342+
'-t',
343+
'0',
344+
'/dev/null',
345+
'xcrun',
346+
'xcdevice',
347+
'observe',
348+
'--both',
349+
],
350+
);
351+
352+
final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess.stdout
353+
.transform<String>(utf8.decoder)
354+
.transform<String>(const LineSplitter())
355+
.listen((String line) {
356+
357+
// xcdevice observe example output of UDIDs:
358+
//
359+
// Listening for all devices, on both interfaces.
360+
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
361+
// Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
362+
// Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
363+
final RegExpMatch match = _observationIdentifierPattern.firstMatch(line);
364+
if (match != null && match.groupCount == 2) {
365+
final String verb = match.group(1).toLowerCase();
366+
final String identifier = match.group(2);
367+
if (verb.startsWith('attach')) {
368+
_deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
369+
XCDeviceEvent.attach: identifier
370+
});
371+
} else if (verb.startsWith('detach')) {
372+
_deviceIdentifierByEvent.add(<XCDeviceEvent, String>{
373+
XCDeviceEvent.detach: identifier
374+
});
375+
}
376+
}
377+
});
378+
final StreamSubscription<String> stderrSubscription = _deviceObservationProcess.stderr
379+
.transform<String>(utf8.decoder)
380+
.transform<String>(const LineSplitter())
381+
.listen((String line) {
382+
_logger.printTrace('xcdevice observe error: $line');
383+
});
384+
unawaited(_deviceObservationProcess.exitCode.then((int status) {
385+
_logger.printTrace('xcdevice exited with code $exitCode');
386+
unawaited(stdoutSubscription.cancel());
387+
unawaited(stderrSubscription.cancel());
388+
}).whenComplete(() async {
389+
if (_deviceIdentifierByEvent.hasListener) {
390+
// Tell listeners the process died.
391+
await _deviceIdentifierByEvent.close();
392+
}
393+
_deviceObservationProcess = null;
394+
395+
// Reopen it so new listeners can resume polling.
396+
_setupDeviceIdentifierByEventStream();
397+
}));
398+
} on ProcessException catch (exception, stackTrace) {
399+
_deviceIdentifierByEvent.addError(exception, stackTrace);
400+
} on ArgumentError catch (exception, stackTrace) {
401+
_deviceIdentifierByEvent.addError(exception, stackTrace);
402+
}
403+
}
404+
405+
void _stopObservingTetheredIOSDevices() {
406+
_deviceObservationProcess?.kill();
407+
}
291408

292409
/// [timeout] defaults to 2 seconds.
293410
Future<List<IOSDevice>> getAvailableTetheredIOSDevices({ Duration timeout }) async {

packages/flutter_tools/test/general.shard/base_utils_test.dart

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,25 @@ void main() {
1818
final Future<List<String>> removedStreamItems = list.onRemoved.toList();
1919

2020
list.updateWithNewList(<String>['aaa']);
21-
list.updateWithNewList(<String>['aaa', 'bbb']);
22-
list.updateWithNewList(<String>['bbb']);
21+
list.removeItem('bogus');
22+
list.updateWithNewList(<String>['aaa', 'bbb', 'ccc']);
23+
list.updateWithNewList(<String>['bbb', 'ccc']);
24+
list.removeItem('bbb');
25+
26+
expect(list.items, <String>['ccc']);
2327
list.dispose();
2428

2529
final List<String> addedItems = await addedStreamItems;
2630
final List<String> removedItems = await removedStreamItems;
2731

28-
expect(addedItems.length, 2);
32+
expect(addedItems.length, 3);
2933
expect(addedItems.first, 'aaa');
3034
expect(addedItems[1], 'bbb');
35+
expect(addedItems[2], 'ccc');
3136

32-
expect(removedItems.length, 1);
37+
expect(removedItems.length, 2);
3338
expect(removedItems.first, 'aaa');
39+
expect(removedItems[1], 'bbb');
3440
});
3541
});
3642
}

0 commit comments

Comments
 (0)