target_devices.dart 29.2 KB
Newer Older
1 2 3 4
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'package:meta/meta.dart';

7 8
import '../base/common.dart';
import '../base/logger.dart';
9 10
import '../base/platform.dart';
import '../base/terminal.dart';
11 12
import '../device.dart';
import '../globals.dart' as globals;
13
import '../ios/devices.dart';
14

15
const String _checkingForWirelessDevicesMessage = 'Checking for wireless devices...';
16
const String _chooseOneMessage = 'Please choose one (or "q" to quit)';
17
const String _connectedDevicesMessage = 'Connected devices:';
18 19 20
const String _foundButUnsupportedDevicesMessage = 'The following devices were found, but are not supported by this project:';
const String _noAttachedCheckForWirelessMessage = 'No devices found yet. Checking for wireless devices...';
const String _noDevicesFoundMessage = 'No devices found.';
21
const String _noWirelessDevicesFoundMessage = 'No wireless devices were found.';
22 23
const String _wirelesslyConnectedDevicesMessage = 'Wirelessly connected devices:';

24 25
String _chooseDeviceOptionMessage(int option, String name, String deviceId) => '[$option]: $name ($deviceId)';
String _foundMultipleSpecifiedDevicesMessage(String deviceId) =>
26
    'Found multiple devices with name or id matching $deviceId:';
27 28 29 30
String _foundSpecifiedDevicesMessage(int count, String deviceId) =>
    'Found $count devices with name or id matching $deviceId:';
String _noMatchingDeviceMessage(String deviceId) => 'No supported devices found with name or id '
    "matching '$deviceId'.";
31 32
String flutterSpecifiedDeviceDevModeDisabled(String deviceName) => 'To use '
    "'$deviceName' for development, enable Developer Mode in Settings → Privacy & Security.";
33

34 35 36 37
/// This class handles functionality of finding and selecting target devices.
///
/// Target devices are devices that are supported and selectable to run
/// a flutter application on.
38
class TargetDevices {
39 40 41 42
  factory TargetDevices({
    required Platform platform,
    required DeviceManager deviceManager,
    required Logger logger,
43
    DeviceConnectionInterface? deviceConnectionInterface,
44 45 46 47 48
  }) {
    if (platform.isMacOS) {
      return TargetDevicesWithExtendedWirelessDeviceDiscovery(
        deviceManager: deviceManager,
        logger: logger,
49
        deviceConnectionInterface: deviceConnectionInterface,
50 51 52 53 54
      );
    }
    return TargetDevices._private(
      deviceManager: deviceManager,
      logger: logger,
55
      deviceConnectionInterface: deviceConnectionInterface,
56 57 58 59
    );
  }

  TargetDevices._private({
60 61
    required DeviceManager deviceManager,
    required Logger logger,
62
    required this.deviceConnectionInterface,
63 64 65 66 67
  })  : _deviceManager = deviceManager,
        _logger = logger;

  final DeviceManager _deviceManager;
  final Logger _logger;
68 69 70 71 72 73 74 75
  final DeviceConnectionInterface? deviceConnectionInterface;

  bool get _includeAttachedDevices =>
      deviceConnectionInterface == null ||
      deviceConnectionInterface == DeviceConnectionInterface.attached;
  bool get _includeWirelessDevices =>
      deviceConnectionInterface == null ||
      deviceConnectionInterface == DeviceConnectionInterface.wireless;
76

77 78 79
  Future<List<Device>> _getAttachedDevices({
    DeviceDiscoverySupportFilter? supportFilter,
  }) async {
80 81 82
    if (!_includeAttachedDevices) {
      return <Device>[];
    }
83 84 85 86 87 88 89 90 91 92 93
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: DeviceConnectionInterface.attached,
        supportFilter: supportFilter,
      ),
    );
  }

  Future<List<Device>> _getWirelessDevices({
    DeviceDiscoverySupportFilter? supportFilter,
  }) async {
94 95 96
    if (!_includeWirelessDevices) {
      return <Device>[];
    }
97 98 99 100 101 102 103 104 105 106
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: DeviceConnectionInterface.wireless,
        supportFilter: supportFilter,
      ),
    );
  }

  Future<List<Device>> _getDeviceById({
    bool includeDevicesUnsupportedByProject = false,
107
    bool includeDisconnected = false,
108 109 110
  }) async {
    return _deviceManager.getDevices(
      filter: DeviceDiscoveryFilter(
111
        excludeDisconnected: !includeDisconnected,
112 113 114
        supportFilter: _deviceManager.deviceSupportFilter(
          includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
        ),
115
        deviceConnectionInterface: deviceConnectionInterface,
116 117 118 119 120 121 122 123 124 125 126 127
      ),
    );
  }

  DeviceDiscoverySupportFilter _defaultSupportFilter(
    bool includeDevicesUnsupportedByProject,
  ) {
    return _deviceManager.deviceSupportFilter(
      includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
    );
  }

128 129 130 131
  void startExtendedWirelessDeviceDiscovery({
    Duration? deviceDiscoveryTimeout,
  }) {}

132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
  /// Find and return all target [Device]s based upon criteria entered by the
  /// user on the command line.
  ///
  /// When the user has specified `all` devices, return all devices meeting criteria.
  ///
  /// When the user has specified a device id/name, attempt to find an exact or
  /// partial match. If an exact match or a single partial match is found,
  /// return it immediately.
  ///
  /// When multiple devices are found and there is a terminal attached to
  /// stdin, allow the user to select which device to use. When a terminal
  /// with stdin is not available, print a list of available devices and
  /// return null.
  ///
  /// When no devices meet user specifications, print a list of unsupported
  /// devices and return null.
148 149 150 151 152
  Future<List<Device>?> findAllTargetDevices({
    Duration? deviceDiscoveryTimeout,
    bool includeDevicesUnsupportedByProject = false,
  }) async {
    if (!globals.doctor!.canLaunchAnything) {
153
      _logger.printError(globals.userMessages.flutterNoDevelopmentDevice);
154 155
      return null;
    }
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176

    if (deviceDiscoveryTimeout != null) {
      // Reset the cache with the specified timeout.
      await _deviceManager.refreshAllDevices(timeout: deviceDiscoveryTimeout);
    }

    if (_deviceManager.hasSpecifiedDeviceId) {
      // Must check for device match separately from `_getAttachedDevices` and
      // `_getWirelessDevices` because if an exact match is found in one
      // and a partial match is found in another, there is no way to distinguish
      // between them.
      final List<Device> devices = await _getDeviceById(
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
      );
      if (devices.length == 1) {
        return devices;
      }
    }

    final List<Device> attachedDevices = await _getAttachedDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
177
    );
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
    final List<Device> wirelessDevices = await _getWirelessDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
    );
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (allDevices.isEmpty) {
      return _handleNoDevices();
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return allDevices;
    } else if (allDevices.length > 1) {
      return _handleMultipleDevices(attachedDevices, wirelessDevices);
    }
    return allDevices;
  }

  /// When no supported devices are found, display a message and list of
  /// unsupported devices found.
  Future<List<Device>?> _handleNoDevices() async {
    // Get connected devices from cache, including unsupported ones.
197 198 199 200 201
    final List<Device> unsupportedDevices = await _deviceManager.getAllDevices(
      filter: DeviceDiscoveryFilter(
        deviceConnectionInterface: deviceConnectionInterface,
      )
    );
202

203 204
    if (_deviceManager.hasSpecifiedDeviceId) {
      _logger.printStatus(
205
        _noMatchingDeviceMessage(_deviceManager.specifiedDeviceId!),
206 207 208 209 210
      );
      if (unsupportedDevices.isNotEmpty) {
        _logger.printStatus('');
        _logger.printStatus('The following devices were found:');
        await Device.printDevices(unsupportedDevices, _logger);
211
      }
212 213 214 215
      return null;
    }

    _logger.printStatus(_deviceManager.hasSpecifiedAllDevices
216
        ? _noDevicesFoundMessage
217
        : globals.userMessages.flutterNoSupportedDevices);
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
    await _printUnsupportedDevice(unsupportedDevices);
    return null;
  }

  /// Determine which device to use when multiple found.
  ///
  /// If user has not specified a device id/name, attempt to prioritize
  /// ephemeral devices. If a single ephemeral device is found, return it
  /// immediately.
  ///
  /// Otherwise, prompt the user to select a device if there is a terminal
  /// with stdin. If there is not a terminal, display the list of devices with
  /// instructions to use a device selection flag.
  Future<List<Device>?> _handleMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(allDevices);
    if (ephemeralDevice != null) {
      return <Device>[ephemeralDevice];
    }

    if (globals.terminal.stdinHasTerminal) {
      return _selectFromMultipleDevices(attachedDevices, wirelessDevices);
    } else {
      return _printMultipleDevices(attachedDevices, wirelessDevices);
    }
  }

  /// Display a list of found devices. When the user has not specified the
  /// device id/name, display devices unsupported by the project as well and
  /// give instructions to use a device selection flag.
  Future<List<Device>?> _printMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    List<Device> supportedAttachedDevices = attachedDevices;
    List<Device> supportedWirelessDevices = wirelessDevices;
    if (_deviceManager.hasSpecifiedDeviceId) {
      final int allDeviceLength = supportedAttachedDevices.length + supportedWirelessDevices.length;
260
      _logger.printStatus(_foundSpecifiedDevicesMessage(
261 262 263 264 265 266 267 268 269 270 271 272 273
        allDeviceLength,
        _deviceManager.specifiedDeviceId!,
      ));
    } else {
      // Get connected devices from cache, including ones unsupported for the
      // project but still supported by Flutter.
      supportedAttachedDevices = await _getAttachedDevices(
        supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutter(),
      );
      supportedWirelessDevices = await _getWirelessDevices(
        supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutter(),
      );

274
      _logger.printStatus(globals.userMessages.flutterSpecifyDeviceWithAllOption);
275 276 277 278 279 280 281 282
      _logger.printStatus('');
    }

    await Device.printDevices(supportedAttachedDevices, _logger);

    if (supportedWirelessDevices.isNotEmpty) {
      if (_deviceManager.hasSpecifiedDeviceId || supportedAttachedDevices.isNotEmpty) {
        _logger.printStatus('');
283
      }
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(supportedWirelessDevices, _logger);
    }

    return null;
  }

  /// Display a list of selectable devices, prompt the user to choose one, and
  /// wait for the user to select a valid option.
  Future<List<Device>?> _selectFromMultipleDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (_deviceManager.hasSpecifiedDeviceId) {
300
      _logger.printStatus(_foundSpecifiedDevicesMessage(
301 302 303 304
        allDevices.length,
        _deviceManager.specifiedDeviceId!,
      ));
    } else {
305
      _logger.printStatus(_connectedDevicesMessage);
306 307 308 309 310 311 312 313 314
    }

    await Device.printDevices(attachedDevices, _logger);

    if (wirelessDevices.isNotEmpty) {
      _logger.printStatus('');
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
      _logger.printStatus('');
315 316
    }

317 318
    final Device chosenDevice = await _chooseOneOfAvailableDevices(allDevices);

319 320
    // Update the [DeviceManager.specifiedDeviceId] so that the user will not
    // be prompted again.
321 322 323
    _deviceManager.specifiedDeviceId = chosenDevice.id;

    return <Device>[chosenDevice];
324 325
  }

326
  Future<void> _printUnsupportedDevice(List<Device> unsupportedDevices) async {
327 328
    if (unsupportedDevices.isNotEmpty) {
      final StringBuffer result = StringBuffer();
329
      result.writeln();
330
      result.writeln(_foundButUnsupportedDevicesMessage);
331 332 333 334 335 336 337
      result.writeAll(
        (await Device.descriptions(unsupportedDevices))
            .map((String desc) => desc)
            .toList(),
        '\n',
      );
      result.writeln();
338
      result.writeln(globals.userMessages.flutterMissPlatformProjects(
339 340
        Device.devicesPlatformTypes(unsupportedDevices),
      ));
341
      _logger.printStatus(result.toString(), newline: false);
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
    }
  }

  Future<Device> _chooseOneOfAvailableDevices(List<Device> devices) async {
    _displayDeviceOptions(devices);
    final String userInput =  await _readUserInput(devices.length);
    if (userInput.toLowerCase() == 'q') {
      throwToolExit('');
    }
    return devices[int.parse(userInput) - 1];
  }

  void _displayDeviceOptions(List<Device> devices) {
    int count = 1;
    for (final Device device in devices) {
357
      _logger.printStatus(_chooseDeviceOptionMessage(count, device.name, device.id));
358 359 360 361 362 363 364 365 366 367
      count++;
    }
  }

  Future<String> _readUserInput(int deviceCount) async {
    globals.terminal.usesTerminalUi = true;
    final String result = await globals.terminal.promptForCharInput(
      <String>[ for (int i = 0; i < deviceCount; i++) '${i + 1}', 'q', 'Q'],
      displayAcceptedCharacters: false,
      logger: _logger,
368
      prompt: _chooseOneMessage,
369 370 371 372
    );
    return result;
  }
}
373 374 375 376 377 378

@visibleForTesting
class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices {
  TargetDevicesWithExtendedWirelessDeviceDiscovery({
    required super.deviceManager,
    required super.logger,
379
    super.deviceConnectionInterface,
380 381 382 383 384 385 386 387 388 389 390 391 392 393
  })  : super._private();

  Future<void>? _wirelessDevicesRefresh;

  @visibleForTesting
  bool waitForWirelessBeforeInput = false;

  @visibleForTesting
  late final TargetDeviceSelection deviceSelection = TargetDeviceSelection(_logger);

  @override
  void startExtendedWirelessDeviceDiscovery({
    Duration? deviceDiscoveryTimeout,
  }) {
394
    if (deviceDiscoveryTimeout == null && _includeWirelessDevices) {
395 396 397 398 399 400 401 402 403 404
      _wirelessDevicesRefresh ??= _deviceManager.refreshExtendedWirelessDeviceDiscoverers(
        timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout,
      );
    }
    return;
  }

  Future<List<Device>> _getRefreshedWirelessDevices({
    bool includeDevicesUnsupportedByProject = false,
  }) async {
405 406 407
    if (!_includeWirelessDevices) {
      return <Device>[];
    }
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462
    startExtendedWirelessDeviceDiscovery();
    return () async {
      await _wirelessDevicesRefresh;
      return _deviceManager.getDevices(
        filter: DeviceDiscoveryFilter(
          deviceConnectionInterface: DeviceConnectionInterface.wireless,
          supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
        ),
      );
    }();
  }

  Future<Device?> _waitForIOSDeviceToConnect(IOSDevice device) async {
    for (final DeviceDiscovery discoverer in _deviceManager.deviceDiscoverers) {
      if (discoverer is IOSDevices) {
        _logger.printStatus('Waiting for ${device.name} to connect...');
        final Status waitingStatus = _logger.startSpinner(
          timeout: const Duration(seconds: 30),
          warningColor: TerminalColor.red,
          slowWarningCallback: () {
            return 'The device was unable to connect after 30 seconds. Ensure the device is paired and unlocked.';
          },
        );
        final Device? connectedDevice = await discoverer.waitForDeviceToConnect(device, _logger);
        waitingStatus.stop();
        return connectedDevice;
      }
    }
    return null;
  }

  /// Find and return all target [Device]s based upon criteria entered by the
  /// user on the command line.
  ///
  /// When the user has specified `all` devices, return all devices meeting criteria.
  ///
  /// When the user has specified a device id/name, attempt to find an exact or
  /// partial match. If an exact match or a single partial match is found and
  /// the device is connected, return it immediately. If an exact match or a
  /// single partial match is found and the device is not connected and it's
  /// an iOS device, wait for it to connect.
  ///
  /// When multiple devices are found and there is a terminal attached to
  /// stdin, allow the user to select which device to use. When a terminal
  /// with stdin is not available, print a list of available devices and
  /// return null.
  ///
  /// When no devices meet user specifications, print a list of unsupported
  /// devices and return null.
  @override
  Future<List<Device>?> findAllTargetDevices({
    Duration? deviceDiscoveryTimeout,
    bool includeDevicesUnsupportedByProject = false,
  }) async {
    if (!globals.doctor!.canLaunchAnything) {
463
      _logger.printError(globals.userMessages.flutterNoDevelopmentDevice);
464 465 466
      return null;
    }

467 468 469 470
    // When a user defines the timeout or filters to only attached devices,
    // use the super function that does not do longer wireless device
    // discovery and does not wait for devices to connect.
    if (deviceDiscoveryTimeout != null || deviceConnectionInterface == DeviceConnectionInterface.attached) {
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489
      return super.findAllTargetDevices(
        deviceDiscoveryTimeout: deviceDiscoveryTimeout,
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
      );
    }

    // Start polling for wireless devices that need longer to load if it hasn't
    // already been started.
    startExtendedWirelessDeviceDiscovery();

    if (_deviceManager.hasSpecifiedDeviceId) {
      // Get devices matching the specified device regardless of whether they
      // are currently connected or not.
      // If there is a single matching connected device, return it immediately.
      // If the only device found is an iOS device that is not connected yet,
      // wait for it to connect.
      // If there are multiple matches, continue on to wait for all attached
      // and wireless devices to load so the user can select between all
      // connected matches.
490
      final List<Device> specifiedDevices = await _getDeviceById(
491 492 493
        includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
        includeDisconnected: true,
      );
494 495 496 497 498 499 500 501 502 503 504 505

      if (specifiedDevices.length == 1) {
        Device? matchedDevice = specifiedDevices.first;
        // If the only matching device does not have Developer Mode enabled,
        // print a warning
        if (matchedDevice is IOSDevice && !matchedDevice.devModeEnabled) {
          _logger.printStatus(
              flutterSpecifiedDeviceDevModeDisabled(matchedDevice.name)
          );
          return null;
        }

506 507 508
        if (!matchedDevice.isConnected && matchedDevice is IOSDevice) {
          matchedDevice = await _waitForIOSDeviceToConnect(matchedDevice);
        }
509

510 511 512
        if (matchedDevice != null && matchedDevice.isConnected) {
          return <Device>[matchedDevice];
        }
513 514 515 516 517 518 519 520 521 522

      } else {
        for (final Device device in specifiedDevices) {
          // Print warning for every matching device that does not have Developer Mode enabled.
          if (device is IOSDevice && !device.devModeEnabled) {
            _logger.printStatus(
                flutterSpecifiedDeviceDevModeDisabled(device.name)
            );
          }
        }
523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557
      }
    }

    final List<Device> attachedDevices = await _getAttachedDevices(
      supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject),
    );

    // _getRefreshedWirelessDevices must be run after _getAttachedDevices is
    // finished to prevent non-iOS discoverers from running simultaneously.
    // `AndroidDevices` may error if run simultaneously.
    final Future<List<Device>> futureWirelessDevices = _getRefreshedWirelessDevices(
      includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
    );

    if (attachedDevices.isEmpty) {
      return _handleNoAttachedDevices(attachedDevices, futureWirelessDevices);
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return _handleAllDevices(attachedDevices, futureWirelessDevices);
    }
    // Even if there's only a single attached device, continue to
    // `_handleRemainingDevices` since there might be wireless devices
    // that are not loaded yet.
    return _handleRemainingDevices(attachedDevices, futureWirelessDevices);
  }

  /// When no supported attached devices are found, wait for wireless devices
  /// to load.
  ///
  /// If no wireless devices are found, continue to `_handleNoDevices`.
  ///
  /// If wireless devices are found, continue to `_handleMultipleDevices`.
  Future<List<Device>?> _handleNoAttachedDevices(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
558
    if (_includeAttachedDevices) {
559
      _logger.printStatus(_noAttachedCheckForWirelessMessage);
560 561 562
    } else {
      _logger.printStatus(_checkingForWirelessDevicesMessage);
    }
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645

    final List<Device> wirelessDevices = await futureWirelessDevices;
    final List<Device> allDevices = attachedDevices + wirelessDevices;

    if (allDevices.isEmpty) {
      _logger.printStatus('');
      return _handleNoDevices();
    } else if (_deviceManager.hasSpecifiedAllDevices) {
      return allDevices;
    } else if (allDevices.length > 1) {
      _logger.printStatus('');
      return _handleMultipleDevices(attachedDevices, wirelessDevices);
    }
    return allDevices;
  }

  /// Wait for wireless devices to load and then return all attached and
  /// wireless devices.
  Future<List<Device>?> _handleAllDevices(
    List<Device> devices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    _logger.printStatus(_checkingForWirelessDevicesMessage);
    final List<Device> wirelessDevices = await futureWirelessDevices;
    return devices + wirelessDevices;
  }

  /// Determine which device to use when one or more are found.
  ///
  /// If user has not specified a device id/name, attempt to prioritize
  /// ephemeral devices. If a single ephemeral device is found, return it
  /// immediately.
  ///
  /// Otherwise, prompt the user to select a device if there is a terminal
  /// with stdin. If there is not a terminal, display the list of devices with
  /// instructions to use a device selection flag.
  Future<List<Device>?> _handleRemainingDevices(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(attachedDevices);
    if (ephemeralDevice != null) {
      return <Device>[ephemeralDevice];
    }

    if (!globals.terminal.stdinHasTerminal || !_logger.supportsColor) {
      _logger.printStatus(_checkingForWirelessDevicesMessage);
      final List<Device> wirelessDevices = await futureWirelessDevices;
      if (attachedDevices.length + wirelessDevices.length == 1) {
        return attachedDevices + wirelessDevices;
      }
      _logger.printStatus('');
      // If the terminal has stdin but does not support color/ANSI (which is
      // needed to clear lines), fallback to standard selection of device.
      if (globals.terminal.stdinHasTerminal && !_logger.supportsColor) {
        return _handleMultipleDevices(attachedDevices, wirelessDevices);
      }
      // If terminal does not have stdin, print out device list.
      return _printMultipleDevices(attachedDevices, wirelessDevices);
    }

    return _selectFromDevicesAndCheckForWireless(
      attachedDevices,
      futureWirelessDevices,
    );
  }

  /// Display a list of selectable attached devices and prompt the user to
  /// choose one.
  ///
  /// Also, display a message about waiting for wireless devices to load. Once
  /// wireless devices have loaded, update waiting message, device list, and
  /// selection options.
  ///
  /// Wait for the user to select a device.
  Future<List<Device>?> _selectFromDevicesAndCheckForWireless(
    List<Device> attachedDevices,
    Future<List<Device>> futureWirelessDevices,
  ) async {
    if (attachedDevices.length == 1 || !_deviceManager.hasSpecifiedDeviceId) {
      _logger.printStatus(_connectedDevicesMessage);
    } else if (_deviceManager.hasSpecifiedDeviceId) {
      // Multiple devices were found with part of the name/id provided.
646
      _logger.printStatus(_foundMultipleSpecifiedDevicesMessage(
647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692
        _deviceManager.specifiedDeviceId!,
      ));
    }

    // Display list of attached devices.
    await Device.printDevices(attachedDevices, _logger);

    // Display waiting message.
    _logger.printStatus('');
    _logger.printStatus(_checkingForWirelessDevicesMessage);
    _logger.printStatus('');

    // Start user device selection so user can select device while waiting
    // for wireless devices to load if they want.
    _displayDeviceOptions(attachedDevices);
    deviceSelection.devices = attachedDevices;
    final Future<Device> futureChosenDevice = deviceSelection.userSelectDevice();
    Device? chosenDevice;

    // Once wireless devices are found, we clear out the waiting message (3),
    // device option list (attachedDevices.length), and device option prompt (1).
    int numLinesToClear = attachedDevices.length + 4;

    futureWirelessDevices = futureWirelessDevices.then((List<Device> wirelessDevices) async {
      // If device is already chosen, don't update terminal with
      // wireless device list.
      if (chosenDevice != null) {
        return wirelessDevices;
      }

      final List<Device> allDevices = attachedDevices + wirelessDevices;

      if (_logger.isVerbose) {
        await _verbosePrintWirelessDevices(attachedDevices, wirelessDevices);
      } else {
        // Also clear any invalid device selections.
        numLinesToClear += deviceSelection.invalidAttempts;
        await _printWirelessDevices(wirelessDevices, numLinesToClear);
      }
      _logger.printStatus('');

      // Reprint device option list.
      _displayDeviceOptions(allDevices);
      deviceSelection.devices = allDevices;
      // Reprint device option prompt.
      _logger.printStatus(
693
        '$_chooseOneMessage: ',
694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775
        emphasis: true,
        newline: false,
      );
      return wirelessDevices;
    });

    // Used for testing.
    if (waitForWirelessBeforeInput) {
      await futureWirelessDevices;
    }

    // Wait for user to select a device.
    chosenDevice = await futureChosenDevice;

    // Update the [DeviceManager.specifiedDeviceId] so that the user will not
    // be prompted again.
    _deviceManager.specifiedDeviceId = chosenDevice.id;

    return <Device>[chosenDevice];
  }

  /// Reprint list of attached devices before printing list of wireless devices.
  Future<void> _verbosePrintWirelessDevices(
    List<Device> attachedDevices,
    List<Device> wirelessDevices,
  ) async {
    if (wirelessDevices.isEmpty) {
      _logger.printStatus(_noWirelessDevicesFoundMessage);
    }
    // The iOS xcdevice outputs once wireless devices are done loading, so
    // reprint attached devices so they're grouped with the wireless ones.
    _logger.printStatus(_connectedDevicesMessage);
    await Device.printDevices(attachedDevices, _logger);

    if (wirelessDevices.isNotEmpty) {
      _logger.printStatus('');
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
    }
  }

  /// Clear [numLinesToClear] lines from terminal. Print message and list of
  /// wireless devices.
  Future<void> _printWirelessDevices(
    List<Device> wirelessDevices,
    int numLinesToClear,
  ) async {
    _logger.printStatus(
      globals.terminal.clearLines(numLinesToClear),
      newline: false,
    );
    _logger.printStatus('');
    if (wirelessDevices.isEmpty) {
      _logger.printStatus(_noWirelessDevicesFoundMessage);
    } else {
      _logger.printStatus(_wirelesslyConnectedDevicesMessage);
      await Device.printDevices(wirelessDevices, _logger);
    }
  }
}

@visibleForTesting
class TargetDeviceSelection {
  TargetDeviceSelection(this._logger);

  List<Device> devices = <Device>[];
  final Logger _logger;
  int invalidAttempts = 0;

  /// Prompt user to select a device and wait until they select a valid device.
  ///
  /// If the user selects `q`, exit the tool.
  ///
  /// If the user selects an invalid number, reprompt them and continue waiting.
  Future<Device> userSelectDevice() async {
    Device? chosenDevice;
    while (chosenDevice == null) {
      final String userInputString = await readUserInput();
      if (userInputString.toLowerCase() == 'q') {
        throwToolExit('');
      }
      final int deviceIndex = int.parse(userInputString) - 1;
776
      if (deviceIndex > -1 && deviceIndex < devices.length) {
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793
        chosenDevice = devices[deviceIndex];
      }
    }

    return chosenDevice;
  }

  /// Prompt user to select a device and wait until they select a valid
  /// character.
  ///
  /// Only allow input of a number or `q`.
  @visibleForTesting
  Future<String> readUserInput() async {
    final RegExp pattern = RegExp(r'\d+$|q', caseSensitive: false);
    String? choice;
    globals.terminal.singleCharMode = true;
    while (choice == null || choice.length > 1 || !pattern.hasMatch(choice)) {
794
      _logger.printStatus(_chooseOneMessage, emphasis: true, newline: false);
795 796 797 798 799 800 801 802 803 804
      // prompt ends with ': '
      _logger.printStatus(': ', emphasis: true, newline: false);
      choice = (await globals.terminal.keystrokes.first).trim();
      _logger.printStatus(choice);
      invalidAttempts++;
    }
    globals.terminal.singleCharMode = false;
    return choice;
  }
}