// 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. import 'package:meta/meta.dart'; import '../base/common.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/terminal.dart'; import '../base/user_messages.dart'; import '../device.dart'; import '../globals.dart' as globals; import '../ios/devices.dart'; const String _checkingForWirelessDevicesMessage = 'Checking for wireless devices...'; const String _chooseOneMessage = 'Please choose one (or "q" to quit)'; const String _connectedDevicesMessage = 'Connected devices:'; 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.'; const String _noWirelessDevicesFoundMessage = 'No wireless devices were found.'; const String _wirelesslyConnectedDevicesMessage = 'Wirelessly connected devices:'; String _chooseDeviceOptionMessage(int option, String name, String deviceId) => '[$option]: $name ($deviceId)'; String _foundMultipleSpecifiedDevicesMessage(String deviceId) => 'Found multiple devices with name or id matching $deviceId:'; 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'."; String flutterSpecifiedDeviceDevModeDisabled(String deviceName) => 'To use ' "'$deviceName' for development, enable Developer Mode in Settings → Privacy & Security."; /// 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. class TargetDevices { factory TargetDevices({ required Platform platform, required DeviceManager deviceManager, required Logger logger, DeviceConnectionInterface? deviceConnectionInterface, }) { if (platform.isMacOS) { return TargetDevicesWithExtendedWirelessDeviceDiscovery( deviceManager: deviceManager, logger: logger, deviceConnectionInterface: deviceConnectionInterface, ); } return TargetDevices._private( deviceManager: deviceManager, logger: logger, deviceConnectionInterface: deviceConnectionInterface, ); } TargetDevices._private({ required DeviceManager deviceManager, required Logger logger, required this.deviceConnectionInterface, }) : _deviceManager = deviceManager, _logger = logger; final DeviceManager _deviceManager; final Logger _logger; final DeviceConnectionInterface? deviceConnectionInterface; bool get _includeAttachedDevices => deviceConnectionInterface == null || deviceConnectionInterface == DeviceConnectionInterface.attached; bool get _includeWirelessDevices => deviceConnectionInterface == null || deviceConnectionInterface == DeviceConnectionInterface.wireless; Future> _getAttachedDevices({ DeviceDiscoverySupportFilter? supportFilter, }) async { if (!_includeAttachedDevices) { return []; } return _deviceManager.getDevices( filter: DeviceDiscoveryFilter( deviceConnectionInterface: DeviceConnectionInterface.attached, supportFilter: supportFilter, ), ); } Future> _getWirelessDevices({ DeviceDiscoverySupportFilter? supportFilter, }) async { if (!_includeWirelessDevices) { return []; } return _deviceManager.getDevices( filter: DeviceDiscoveryFilter( deviceConnectionInterface: DeviceConnectionInterface.wireless, supportFilter: supportFilter, ), ); } Future> _getDeviceById({ bool includeDevicesUnsupportedByProject = false, bool includeDisconnected = false, }) async { return _deviceManager.getDevices( filter: DeviceDiscoveryFilter( excludeDisconnected: !includeDisconnected, supportFilter: _deviceManager.deviceSupportFilter( includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject, ), deviceConnectionInterface: deviceConnectionInterface, ), ); } DeviceDiscoverySupportFilter _defaultSupportFilter( bool includeDevicesUnsupportedByProject, ) { return _deviceManager.deviceSupportFilter( includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject, ); } void startExtendedWirelessDeviceDiscovery({ Duration? deviceDiscoveryTimeout, }) {} /// 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. Future?> findAllTargetDevices({ Duration? deviceDiscoveryTimeout, bool includeDevicesUnsupportedByProject = false, }) async { if (!globals.doctor!.canLaunchAnything) { _logger.printError(userMessages.flutterNoDevelopmentDevice); return null; } 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 devices = await _getDeviceById( includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject, ); if (devices.length == 1) { return devices; } } final List attachedDevices = await _getAttachedDevices( supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject), ); final List wirelessDevices = await _getWirelessDevices( supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject), ); final List 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?> _handleNoDevices() async { // Get connected devices from cache, including unsupported ones. final List unsupportedDevices = await _deviceManager.getAllDevices( filter: DeviceDiscoveryFilter( deviceConnectionInterface: deviceConnectionInterface, ) ); if (_deviceManager.hasSpecifiedDeviceId) { _logger.printStatus( _noMatchingDeviceMessage(_deviceManager.specifiedDeviceId!), ); if (unsupportedDevices.isNotEmpty) { _logger.printStatus(''); _logger.printStatus('The following devices were found:'); await Device.printDevices(unsupportedDevices, _logger); } return null; } _logger.printStatus(_deviceManager.hasSpecifiedAllDevices ? _noDevicesFoundMessage : userMessages.flutterNoSupportedDevices); 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?> _handleMultipleDevices( List attachedDevices, List wirelessDevices, ) async { final List allDevices = attachedDevices + wirelessDevices; final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(allDevices); if (ephemeralDevice != null) { return [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?> _printMultipleDevices( List attachedDevices, List wirelessDevices, ) async { List supportedAttachedDevices = attachedDevices; List supportedWirelessDevices = wirelessDevices; if (_deviceManager.hasSpecifiedDeviceId) { final int allDeviceLength = supportedAttachedDevices.length + supportedWirelessDevices.length; _logger.printStatus(_foundSpecifiedDevicesMessage( 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(), ); _logger.printStatus(userMessages.flutterSpecifyDeviceWithAllOption); _logger.printStatus(''); } await Device.printDevices(supportedAttachedDevices, _logger); if (supportedWirelessDevices.isNotEmpty) { if (_deviceManager.hasSpecifiedDeviceId || supportedAttachedDevices.isNotEmpty) { _logger.printStatus(''); } _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?> _selectFromMultipleDevices( List attachedDevices, List wirelessDevices, ) async { final List allDevices = attachedDevices + wirelessDevices; if (_deviceManager.hasSpecifiedDeviceId) { _logger.printStatus(_foundSpecifiedDevicesMessage( allDevices.length, _deviceManager.specifiedDeviceId!, )); } else { _logger.printStatus(_connectedDevicesMessage); } await Device.printDevices(attachedDevices, _logger); if (wirelessDevices.isNotEmpty) { _logger.printStatus(''); _logger.printStatus(_wirelesslyConnectedDevicesMessage); await Device.printDevices(wirelessDevices, _logger); _logger.printStatus(''); } final Device chosenDevice = await _chooseOneOfAvailableDevices(allDevices); // Update the [DeviceManager.specifiedDeviceId] so that the user will not // be prompted again. _deviceManager.specifiedDeviceId = chosenDevice.id; return [chosenDevice]; } Future _printUnsupportedDevice(List unsupportedDevices) async { if (unsupportedDevices.isNotEmpty) { final StringBuffer result = StringBuffer(); result.writeln(); result.writeln(_foundButUnsupportedDevicesMessage); result.writeAll( (await Device.descriptions(unsupportedDevices)) .map((String desc) => desc) .toList(), '\n', ); result.writeln(); result.writeln(userMessages.flutterMissPlatformProjects( Device.devicesPlatformTypes(unsupportedDevices), )); _logger.printStatus(result.toString(), newline: false); } } Future _chooseOneOfAvailableDevices(List 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 devices) { int count = 1; for (final Device device in devices) { _logger.printStatus(_chooseDeviceOptionMessage(count, device.name, device.id)); count++; } } Future _readUserInput(int deviceCount) async { globals.terminal.usesTerminalUi = true; final String result = await globals.terminal.promptForCharInput( [ for (int i = 0; i < deviceCount; i++) '${i + 1}', 'q', 'Q'], displayAcceptedCharacters: false, logger: _logger, prompt: _chooseOneMessage, ); return result; } } @visibleForTesting class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { TargetDevicesWithExtendedWirelessDeviceDiscovery({ required super.deviceManager, required super.logger, super.deviceConnectionInterface, }) : super._private(); Future? _wirelessDevicesRefresh; @visibleForTesting bool waitForWirelessBeforeInput = false; @visibleForTesting late final TargetDeviceSelection deviceSelection = TargetDeviceSelection(_logger); @override void startExtendedWirelessDeviceDiscovery({ Duration? deviceDiscoveryTimeout, }) { if (deviceDiscoveryTimeout == null && _includeWirelessDevices) { _wirelessDevicesRefresh ??= _deviceManager.refreshExtendedWirelessDeviceDiscoverers( timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, ); } return; } Future> _getRefreshedWirelessDevices({ bool includeDevicesUnsupportedByProject = false, }) async { if (!_includeWirelessDevices) { return []; } startExtendedWirelessDeviceDiscovery(); return () async { await _wirelessDevicesRefresh; return _deviceManager.getDevices( filter: DeviceDiscoveryFilter( deviceConnectionInterface: DeviceConnectionInterface.wireless, supportFilter: _defaultSupportFilter(includeDevicesUnsupportedByProject), ), ); }(); } Future _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?> findAllTargetDevices({ Duration? deviceDiscoveryTimeout, bool includeDevicesUnsupportedByProject = false, }) async { if (!globals.doctor!.canLaunchAnything) { _logger.printError(userMessages.flutterNoDevelopmentDevice); return null; } // 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) { 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. final List specifiedDevices = await _getDeviceById( includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject, includeDisconnected: true, ); 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; } if (!matchedDevice.isConnected && matchedDevice is IOSDevice) { matchedDevice = await _waitForIOSDeviceToConnect(matchedDevice); } if (matchedDevice != null && matchedDevice.isConnected) { return [matchedDevice]; } } 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) ); } } } } final List 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> 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?> _handleNoAttachedDevices( List attachedDevices, Future> futureWirelessDevices, ) async { if (_includeAttachedDevices) { _logger.printStatus(_noAttachedCheckForWirelessMessage); } else { _logger.printStatus(_checkingForWirelessDevicesMessage); } final List wirelessDevices = await futureWirelessDevices; final List 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?> _handleAllDevices( List devices, Future> futureWirelessDevices, ) async { _logger.printStatus(_checkingForWirelessDevicesMessage); final List 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?> _handleRemainingDevices( List attachedDevices, Future> futureWirelessDevices, ) async { final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(attachedDevices); if (ephemeralDevice != null) { return [ephemeralDevice]; } if (!globals.terminal.stdinHasTerminal || !_logger.supportsColor) { _logger.printStatus(_checkingForWirelessDevicesMessage); final List 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?> _selectFromDevicesAndCheckForWireless( List attachedDevices, Future> 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. _logger.printStatus(_foundMultipleSpecifiedDevicesMessage( _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 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 wirelessDevices) async { // If device is already chosen, don't update terminal with // wireless device list. if (chosenDevice != null) { return wirelessDevices; } final List 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( '$_chooseOneMessage: ', 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 [chosenDevice]; } /// Reprint list of attached devices before printing list of wireless devices. Future _verbosePrintWirelessDevices( List attachedDevices, List 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 _printWirelessDevices( List 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 devices = []; 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 userSelectDevice() async { Device? chosenDevice; while (chosenDevice == null) { final String userInputString = await readUserInput(); if (userInputString.toLowerCase() == 'q') { throwToolExit(''); } final int deviceIndex = int.parse(userInputString) - 1; if (deviceIndex > -1 && deviceIndex < devices.length) { 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 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)) { _logger.printStatus(_chooseOneMessage, emphasis: true, newline: false); // 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; } }