Add --device-timeout flag to device-related commands (#64834)

diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
index 7272767..0cde25a 100644
--- a/packages/flutter_tools/lib/src/commands/attach.dart
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -102,6 +102,7 @@
       );
     usesTrackWidgetCreation(verboseHelp: verboseHelp);
     addDdsOptions(verboseHelp: verboseHelp);
+    usesDeviceTimeoutOption();
     hotRunnerFactory ??= HotRunnerFactory();
   }
 
diff --git a/packages/flutter_tools/lib/src/commands/devices.dart b/packages/flutter_tools/lib/src/commands/devices.dart
index 53c9eea..b513539 100644
--- a/packages/flutter_tools/lib/src/commands/devices.dart
+++ b/packages/flutter_tools/lib/src/commands/devices.dart
@@ -22,8 +22,9 @@
       'timeout',
       abbr: 't',
       defaultsTo: null,
-      help: 'Time in seconds to wait for devices to attach. Longer timeouts may be necessary for networked devices.'
+      help: '(deprecated) Use --device-timeout instead',
     );
+    usesDeviceTimeoutOption();
   }
 
   @override
@@ -32,20 +33,25 @@
   @override
   final String description = 'List all connected devices.';
 
-  Duration get timeout {
-    if (argResults['timeout'] == null) {
-      return null;
-    }
-    if (_timeout == null) {
+  @override
+  Duration get deviceDiscoveryTimeout {
+    if (argResults['timeout'] != null) {
       final int timeoutSeconds = int.tryParse(stringArg('timeout'));
       if (timeoutSeconds == null) {
         throwToolExit( 'Could not parse -t/--timeout argument. It must be an integer.');
       }
-      _timeout = Duration(seconds: timeoutSeconds);
+      return Duration(seconds: timeoutSeconds);
     }
-    return _timeout;
+    return super.deviceDiscoveryTimeout;
   }
-  Duration _timeout;
+
+  @override
+  Future<void> validateCommand() {
+    if (argResults['timeout'] != null) {
+      globals.printError('--timeout has been deprecated, use --${FlutterOptions.kDeviceTimeout} instead');
+    }
+    return super.validateCommand();
+  }
 
   @override
   Future<FlutterCommandResult> runCommand() async {
@@ -56,7 +62,7 @@
         exitCode: 1);
     }
 
-    final List<Device> devices = await globals.deviceManager.refreshAllConnectedDevices(timeout: timeout);
+    final List<Device> devices = await globals.deviceManager.refreshAllConnectedDevices(timeout: deviceDiscoveryTimeout);
 
     if (boolArg('machine')) {
       await printDevicesAsJson(devices);
@@ -68,8 +74,8 @@
         status.writeln('Run "flutter emulators" to list and start any available device emulators.');
         status.writeln();
         status.write('If you expected your device to be detected, please run "flutter doctor" to diagnose potential issues. ');
-        if (timeout == null) {
-          status.write('You may also try increasing the time to wait for connected devices with the --timeout flag. ');
+        if (deviceDiscoveryTimeout == null) {
+          status.write('You may also try increasing the time to wait for connected devices with the --${FlutterOptions.kDeviceTimeout} flag. ');
         }
         status.write('Visit https://flutter.dev/setup/ for troubleshooting tips.');
 
diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart
index 0980e68..1f9772e 100644
--- a/packages/flutter_tools/lib/src/commands/drive.dart
+++ b/packages/flutter_tools/lib/src/commands/drive.dart
@@ -146,7 +146,7 @@
   @override
   Future<void> validateCommand() async {
     if (userIdentifier != null) {
-      final Device device = await findTargetDevice();
+      final Device device = await findTargetDevice(timeout: deviceDiscoveryTimeout);
       if (device is! AndroidDevice) {
         throwToolExit('--${FlutterOptions.kDeviceUser} is only supported for Android');
       }
@@ -161,7 +161,7 @@
       throwToolExit(null);
     }
 
-    _device = await findTargetDevice();
+    _device = await findTargetDevice(timeout: deviceDiscoveryTimeout);
     if (device == null) {
       throwToolExit(null);
     }
@@ -388,9 +388,9 @@
   }
 }
 
-Future<Device> findTargetDevice() async {
+Future<Device> findTargetDevice({ @required Duration timeout }) async {
   final DeviceManager deviceManager = globals.deviceManager;
-  final List<Device> devices = await deviceManager.findTargetDevices(FlutterProject.current());
+  final List<Device> devices = await deviceManager.findTargetDevices(FlutterProject.current(), timeout: timeout);
 
   if (deviceManager.hasSpecifiedDeviceId) {
     if (devices.isEmpty) {
diff --git a/packages/flutter_tools/lib/src/commands/install.dart b/packages/flutter_tools/lib/src/commands/install.dart
index 8228cbf..62fd7cd 100644
--- a/packages/flutter_tools/lib/src/commands/install.dart
+++ b/packages/flutter_tools/lib/src/commands/install.dart
@@ -16,6 +16,7 @@
   InstallCommand() {
     requiresPubspecYaml();
     usesDeviceUserOption();
+    usesDeviceTimeoutOption();
     argParser.addFlag('uninstall-only',
       negatable: true,
       defaultsTo: false,
diff --git a/packages/flutter_tools/lib/src/commands/logs.dart b/packages/flutter_tools/lib/src/commands/logs.dart
index 5b9b9cb..5305e14 100644
--- a/packages/flutter_tools/lib/src/commands/logs.dart
+++ b/packages/flutter_tools/lib/src/commands/logs.dart
@@ -18,6 +18,7 @@
       abbr: 'c',
       help: 'Clear log history before reading from logs.',
     );
+    usesDeviceTimeoutOption();
   }
 
   @override
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index fd5b739..b70a1a7 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -75,6 +75,7 @@
     usesTrackWidgetCreation(verboseHelp: verboseHelp);
     addNullSafetyModeOptions(hide: !verboseHelp);
     usesDeviceUserOption();
+    usesDeviceTimeoutOption();
   }
 
   bool get traceStartup => boolArg('trace-startup');
diff --git a/packages/flutter_tools/lib/src/commands/screenshot.dart b/packages/flutter_tools/lib/src/commands/screenshot.dart
index 3c32de7..566affd 100644
--- a/packages/flutter_tools/lib/src/commands/screenshot.dart
+++ b/packages/flutter_tools/lib/src/commands/screenshot.dart
@@ -51,6 +51,7 @@
       },
       defaultsTo: _kDeviceType,
     );
+    usesDeviceTimeoutOption();
   }
 
   @override
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index 2a16f8e..84d0a05 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -209,7 +209,12 @@
   /// * If the user did not specify a device id and there is more than one
   /// device connected, then filter out unsupported devices and prioritize
   /// ephemeral devices.
-  Future<List<Device>> findTargetDevices(FlutterProject flutterProject) async {
+  Future<List<Device>> findTargetDevices(FlutterProject flutterProject, { Duration timeout }) async {
+    if (timeout != null) {
+      // Reset the cache with the specified timeout.
+      await refreshAllConnectedDevices(timeout: timeout);
+    }
+
     List<Device> devices = await getDevices();
 
     // Always remove web and fuchsia devices from `--all`. This setting
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart
index e42548b..8fa2413 100644
--- a/packages/flutter_tools/lib/src/runner/flutter_command.dart
+++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart
@@ -113,6 +113,7 @@
   static const String kPerformanceMeasurementFile = 'performance-measurement-file';
   static const String kNullSafety = 'sound-null-safety';
   static const String kDeviceUser = 'device-user';
+  static const String kDeviceTimeout = 'device-timeout';
   static const String kAnalyzeSize = 'analyze-size';
   static const String kNullAssertions = 'null-assertions';
 }
@@ -404,6 +405,28 @@
       valueHelp: '10');
   }
 
+  void usesDeviceTimeoutOption() {
+    argParser.addOption(
+      FlutterOptions.kDeviceTimeout,
+      help: 'Time in seconds to wait for devices to attach. Longer timeouts may be necessary for networked devices.',
+      valueHelp: '10'
+    );
+  }
+
+  Duration get deviceDiscoveryTimeout {
+    if (_deviceDiscoveryTimeout == null
+        && argResults.options.contains(FlutterOptions.kDeviceTimeout)
+        && argResults.wasParsed(FlutterOptions.kDeviceTimeout)) {
+      final int timeoutSeconds = int.tryParse(stringArg(FlutterOptions.kDeviceTimeout));
+      if (timeoutSeconds == null) {
+        throwToolExit( 'Could not parse --${FlutterOptions.kDeviceTimeout} argument. It must be an integer.');
+      }
+      _deviceDiscoveryTimeout = Duration(seconds: timeoutSeconds);
+    }
+    return _deviceDiscoveryTimeout;
+  }
+  Duration _deviceDiscoveryTimeout;
+
   void addBuildModeFlags({ bool defaultToRelease = true, bool verboseHelp = false, bool excludeDebug = false }) {
     // A release build must be the default if a debug build is not possible.
     assert(defaultToRelease || !excludeDebug);
@@ -970,7 +993,7 @@
       return null;
     }
     final DeviceManager deviceManager = globals.deviceManager;
-    List<Device> devices = await deviceManager.findTargetDevices(FlutterProject.current());
+    List<Device> devices = await deviceManager.findTargetDevices(FlutterProject.current(), timeout: deviceDiscoveryTimeout);
 
     if (devices.isEmpty && deviceManager.hasSpecifiedDeviceId) {
       globals.printStatus(userMessages.flutterNoMatchingDevice(deviceManager.specifiedDeviceId));
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
index c2a94e1..c820e5a 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
@@ -342,6 +342,8 @@
         '-v',
         '--device-user',
         '10',
+        '--device-timeout',
+        '15',
       ]);
       final VerificationResult verificationResult = verify(
         mockHotRunnerFactory.build(
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart
index 8a36a9e..7f33308 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart
@@ -281,7 +281,7 @@
         when(mockDevice.name).thenReturn('specified-device');
         when(mockDevice.id).thenReturn('123');
 
-        final Device device = await findTargetDevice();
+        final Device device = await findTargetDevice(timeout: null);
         expect(device.name, 'specified-device');
       }, overrides: <Type, Generator>{
         FileSystem: () => fs,
@@ -293,7 +293,7 @@
       Platform platform() => FakePlatform(operatingSystem: operatingSystem);
 
       testUsingContext('returns null if no devices found', () async {
-        expect(await findTargetDevice(), isNull);
+        expect(await findTargetDevice(timeout: null), isNull);
       }, overrides: <Type, Generator>{
         FileSystem: () => fs,
         ProcessManager: () => FakeProcessManager.any(),
@@ -305,7 +305,7 @@
         when(mockDevice.name).thenReturn('mock-android-device');
         testDeviceManager.addDevice(mockDevice);
 
-        final Device device = await findTargetDevice();
+        final Device device = await findTargetDevice(timeout: null);
         expect(device.name, 'mock-android-device');
       }, overrides: <Type, Generator>{
         FileSystem: () => fs,
@@ -337,7 +337,7 @@
         testDeviceManager.addDevice(mockDevice);
         testDeviceManager.addDevice(mockUnsupportedDevice);
 
-        final Device device = await findTargetDevice();
+        final Device device = await findTargetDevice(timeout: null);
         expect(device.name, 'mock-android-device');
       }, overrides: <Type, Generator>{
         FileSystem: () => fs,
@@ -366,7 +366,7 @@
         when(mockDevice.isLocalEmulator)
             .thenAnswer((Invocation invocation) => Future<bool>.value(true));
 
-        final Device device = await findTargetDevice();
+        final Device device = await findTargetDevice(timeout: null);
         expect(device.name, 'mock-simulator');
       }, overrides: <Type, Generator>{
         FileSystem: () => fs,
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
index 52f3324..edd6060 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart
@@ -178,7 +178,7 @@
         when(mockDeviceManager.getDevices()).thenAnswer(
           (Invocation invocation) => Future<List<Device>>.value(noDevices)
         );
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer(
+        when(mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout'))).thenAnswer(
           (Invocation invocation) => Future<List<Device>>.value(noDevices)
         );
 
@@ -214,7 +214,7 @@
         when(mockDeviceManager.getDevices()).thenAnswer((Invocation invocation) async {
           return <Device>[device];
         });
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer((Invocation invocation) async {
+        when(mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout'))).thenAnswer((Invocation invocation) async {
           return <Device>[device];
         });
         when(mockDeviceManager.hasSpecifiedAllDevices).thenReturn(false);
@@ -254,7 +254,7 @@
           ]);
         });
 
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer(
+        when(mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout'))).thenAnswer(
             (Invocation invocation) => Future<List<Device>>.value(<Device>[]),
         );
 
@@ -301,7 +301,8 @@
         );
         // No devices are attached, we just want to verify update the cache
         // BEFORE checking for devices
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer(
+        const Duration timeout = Duration(seconds: 10);
+        when(mockDeviceManager.findTargetDevices(any, timeout: timeout)).thenAnswer(
           (Invocation invocation) => Future<List<Device>>.value(<Device>[])
         );
 
@@ -309,6 +310,8 @@
           await createTestCommandRunner(command).run(<String>[
             'run',
             '--no-pub',
+            '--device-timeout',
+            '10',
           ]);
           fail('Exception expected');
         } on ToolExit catch (e) {
@@ -324,7 +327,7 @@
           // as part of gathering `requiredArtifacts`
           mockDeviceManager.getDevices(),
           // in validateCommand()
-          mockDeviceManager.findTargetDevices(any),
+          mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout')),
         ]);
       }, overrides: <Type, Generator>{
         ApplicationPackageFactory: () => mockApplicationPackageFactory,
@@ -365,7 +368,7 @@
           (Invocation invocation) => Future<List<Device>>.value(<Device>[mockDevice])
         );
 
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer(
+        when(mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout'))).thenAnswer(
           (Invocation invocation) => Future<List<Device>>.value(<Device>[mockDevice])
         );
 
@@ -434,7 +437,7 @@
         when(mockDeviceManager.getDevices()).thenAnswer((Invocation invocation) {
           return Future<List<Device>>.value(<Device>[fakeDevice]);
         });
-        when(mockDeviceManager.findTargetDevices(any)).thenAnswer(
+        when(mockDeviceManager.findTargetDevices(any, timeout: anyNamed('timeout'))).thenAnswer(
           (Invocation invocation) => Future<List<Device>>.value(<Device>[fakeDevice])
         );
       });
diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart
index c39d428..f2e62ec 100644
--- a/packages/flutter_tools/test/general.shard/device_test.dart
+++ b/packages/flutter_tools/test/general.shard/device_test.dart
@@ -58,8 +58,10 @@
       // 3. A device discoverer that succeeds.
       final DeviceManager deviceManager = TestDeviceManager(
         devices,
-        testLongPollingDeviceDiscovery: true,
-        testThrowingDeviceDiscovery: true,
+        deviceDiscoveryOverrides: <DeviceDiscovery>[
+          ThrowingPollingDeviceDiscovery(),
+          LongPollingDeviceDiscovery(),
+        ],
       );
 
       Future<void> expectDevice(String id, List<Device> expected) async {
@@ -89,7 +91,9 @@
       // 2. A device discoverer that succeeds.
       final DeviceManager deviceManager = TestDeviceManager(
         devices,
-        testThrowingDeviceDiscovery: true
+        deviceDiscoveryOverrides: <DeviceDiscovery>[
+          ThrowingPollingDeviceDiscovery(),
+        ],
       );
 
       Future<void> expectDevice(String id, List<Device> expected) async {
@@ -387,7 +391,62 @@
       Artifacts: () => Artifacts.test(),
       Cache: () => cache,
     });
+
+    testUsingContext('does not refresh device cache without a timeout', () async {
+      final List<Device> devices = <Device>[
+        ephemeralOne,
+      ];
+      final MockDeviceDiscovery mockDeviceDiscovery = MockDeviceDiscovery();
+      when(mockDeviceDiscovery.supportsPlatform).thenReturn(true);
+      // when(mockDeviceDiscovery.discoverDevices(timeout: timeout)).thenAnswer((_) async => devices);
+      when(mockDeviceDiscovery.devices).thenAnswer((_) async => devices);
+      // when(mockDeviceDiscovery.discoverDevices(timeout: timeout)).thenAnswer((_) async => devices);
+
+      final DeviceManager deviceManager = TestDeviceManager(<Device>[], deviceDiscoveryOverrides: <DeviceDiscovery>[
+        mockDeviceDiscovery
+      ]);
+      deviceManager.specifiedDeviceId = ephemeralOne.id;
+      final List<Device> filtered = await deviceManager.findTargetDevices(
+        FlutterProject.current(),
+      );
+
+      expect(filtered.single, ephemeralOne);
+      verify(mockDeviceDiscovery.devices).called(1);
+      verifyNever(mockDeviceDiscovery.discoverDevices(timeout: anyNamed('timeout')));
+    }, overrides: <Type, Generator>{
+      Artifacts: () => Artifacts.test(),
+      Cache: () => cache,
+    });
+
+    testUsingContext('refreshes device cache with a timeout', () async {
+      final List<Device> devices = <Device>[
+        ephemeralOne,
+      ];
+      const Duration timeout = Duration(seconds: 2);
+      final MockDeviceDiscovery mockDeviceDiscovery = MockDeviceDiscovery();
+      when(mockDeviceDiscovery.supportsPlatform).thenReturn(true);
+      when(mockDeviceDiscovery.discoverDevices(timeout: timeout)).thenAnswer((_) async => devices);
+      when(mockDeviceDiscovery.devices).thenAnswer((_) async => devices);
+      // when(mockDeviceDiscovery.discoverDevices(timeout: timeout)).thenAnswer((_) async => devices);
+
+      final DeviceManager deviceManager = TestDeviceManager(<Device>[], deviceDiscoveryOverrides: <DeviceDiscovery>[
+        mockDeviceDiscovery
+      ]);
+      deviceManager.specifiedDeviceId = ephemeralOne.id;
+      final List<Device> filtered = await deviceManager.findTargetDevices(
+        FlutterProject.current(),
+        timeout: timeout,
+      );
+
+      expect(filtered.single, ephemeralOne);
+      verify(mockDeviceDiscovery.devices).called(1);
+      verify(mockDeviceDiscovery.discoverDevices(timeout: anyNamed('timeout'))).called(1);
+    }, overrides: <Type, Generator>{
+      Artifacts: () => Artifacts.test(),
+      Cache: () => cache,
+    });
   });
+
   group('ForwardedPort', () {
     group('dispose()', () {
       testUsingContext('does not throw exception if no process is present', () {
@@ -427,16 +486,13 @@
 
 class TestDeviceManager extends DeviceManager {
     TestDeviceManager(List<Device> allDevices, {
-    bool testLongPollingDeviceDiscovery = false,
-    bool testThrowingDeviceDiscovery = false,
+    List<DeviceDiscovery> deviceDiscoveryOverrides,
   }) {
     _fakeDeviceDiscoverer = FakePollingDeviceDiscovery();
     _deviceDiscoverers = <DeviceDiscovery>[
-      if (testLongPollingDeviceDiscovery)
-        LongPollingDeviceDiscovery(),
-      if (testThrowingDeviceDiscovery)
-        ThrowingPollingDeviceDiscovery(),
       _fakeDeviceDiscoverer,
+      if (deviceDiscoveryOverrides != null)
+        ...deviceDiscoveryOverrides
     ];
     resetDevices(allDevices);
   }
@@ -464,3 +520,4 @@
 class MockTerminal extends Mock implements AnsiTerminal {}
 class MockStdio extends Mock implements Stdio {}
 class MockCache extends Mock implements Cache {}
+class MockDeviceDiscovery extends Mock implements DeviceDiscovery {}
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart
index 559f362..f244796 100644
--- a/packages/flutter_tools/test/src/context.dart
+++ b/packages/flutter_tools/test/src/context.dart
@@ -248,7 +248,7 @@
   }
 
   @override
-  Future<List<Device>> findTargetDevices(FlutterProject flutterProject) async {
+  Future<List<Device>> findTargetDevices(FlutterProject flutterProject, { Duration timeout }) async {
     return devices;
   }
 }