Detect USB/network interface from iOS devices (#58257)

diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index 9012d3e..51e354d 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -58,7 +58,7 @@
       );
     }
 
-    return await _xcdevice.getAvailableTetheredIOSDevices(timeout: timeout);
+    return await _xcdevice.getAvailableIOSDevices(timeout: timeout);
   }
 
   @override
@@ -73,11 +73,18 @@
   }
 }
 
+enum IOSDeviceInterface {
+  none,
+  usb,
+  network,
+}
+
 class IOSDevice extends Device {
   IOSDevice(String id, {
     @required FileSystem fileSystem,
     @required this.name,
     @required this.cpuArchitecture,
+    @required this.interfaceType,
     @required String sdkVersion,
     @required Platform platform,
     @required Artifacts artifacts,
@@ -123,16 +130,21 @@
   }
 
   @override
-  bool get supportsHotReload => true;
+  bool get supportsHotReload => interfaceType == IOSDeviceInterface.usb;
 
   @override
-  bool get supportsHotRestart => true;
+  bool get supportsHotRestart => interfaceType == IOSDeviceInterface.usb;
+
+  @override
+  bool get supportsFlutterExit => interfaceType == IOSDeviceInterface.usb;
 
   @override
   final String name;
 
   final DarwinArch cpuArchitecture;
 
+  final IOSDeviceInterface interfaceType;
+
   Map<IOSApp, DeviceLogReader> _logReaders;
 
   DevicePortForwarder _portForwarder;
@@ -178,6 +190,7 @@
         deviceId: id,
         bundlePath: bundle.path,
         launchArguments: <String>[],
+        interfaceType: interfaceType,
       );
     } on ProcessException catch (e) {
       _logger.printError(e.message);
@@ -319,6 +332,7 @@
         deviceId: id,
         bundlePath: bundle.path,
         launchArguments: launchArguments,
+        interfaceType: interfaceType,
       );
       if (installationResult != 0) {
         _logger.printError('Could not run ${bundle.path} on $id.');
@@ -410,7 +424,7 @@
   void clearLogs() { }
 
   @override
-  bool get supportsScreenshot => _iMobileDevice.isInstalled;
+  bool get supportsScreenshot => _iMobileDevice.isInstalled && interfaceType == IOSDeviceInterface.usb;
 
   @override
   Future<void> takeScreenshot(File outputFile) async {
diff --git a/packages/flutter_tools/lib/src/ios/ios_deploy.dart b/packages/flutter_tools/lib/src/ios/ios_deploy.dart
index 246f263..fa97755 100644
--- a/packages/flutter_tools/lib/src/ios/ios_deploy.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_deploy.dart
@@ -14,6 +14,7 @@
 import '../build_info.dart';
 import '../cache.dart';
 import 'code_signing.dart';
+import 'devices.dart';
 
 // Error message patterns from ios-deploy output
 const String noProvisioningProfileErrorOne = 'Error 0xe8008015';
@@ -84,6 +85,7 @@
     @required String deviceId,
     @required String bundlePath,
     @required List<String>launchArguments,
+    @required IOSDeviceInterface interfaceType,
   }) async {
     final List<String> launchCommand = <String>[
       _binaryPath,
@@ -91,7 +93,8 @@
       deviceId,
       '--bundle',
       bundlePath,
-      '--no-wifi',
+      if (interfaceType != IOSDeviceInterface.network)
+        '--no-wifi',
       if (launchArguments.isNotEmpty) ...<String>[
         '--args',
         launchArguments.join(' '),
@@ -113,6 +116,7 @@
     @required String deviceId,
     @required String bundlePath,
     @required List<String> launchArguments,
+    @required IOSDeviceInterface interfaceType,
   }) async {
     final List<String> launchCommand = <String>[
       _binaryPath,
@@ -120,7 +124,8 @@
       deviceId,
       '--bundle',
       bundlePath,
-      '--no-wifi',
+      if (interfaceType != IOSDeviceInterface.network)
+        '--no-wifi',
       '--justlaunch',
       if (launchArguments.isNotEmpty) ...<String>[
         '--args',
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
index e7c6410..ceb0eb5 100644
--- a/packages/flutter_tools/lib/src/macos/xcode.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -290,7 +290,7 @@
   List<dynamic> _cachedListResults;
 
   /// [timeout] defaults to 2 seconds.
-  Future<List<IOSDevice>> getAvailableTetheredIOSDevices({ Duration timeout }) async {
+  Future<List<IOSDevice>> getAvailableIOSDevices({ Duration timeout }) async {
     final List<dynamic> allAvailableDevices = await _getAllDevices(timeout: timeout ?? const Duration(seconds: 2));
 
     if (allAvailableDevices == null) {
@@ -364,8 +364,11 @@
         }
       }
 
+      final IOSDeviceInterface interface = _interfaceType(deviceProperties);
+
       // Only support USB devices, skip "network" interface (Xcode > Window > Devices and Simulators > Connect via network).
-      if (!_isUSBTethered(deviceProperties)) {
+      // TODO(jmagman): Remove this check once wirelessly detected devices can be observed and attached, https://github.com/flutter/flutter/issues/15072.
+      if (interface != IOSDeviceInterface.usb) {
         continue;
       }
 
@@ -373,6 +376,7 @@
         device['identifier'] as String,
         name: device['name'] as String,
         cpuArchitecture: _cpuArchitecture(deviceProperties),
+        interfaceType: interface,
         sdkVersion: _sdkVersion(deviceProperties),
         artifacts: globals.artifacts,
         fileSystem: globals.fs,
@@ -409,10 +413,18 @@
     return null;
   }
 
-  static bool _isUSBTethered(Map<String, dynamic> deviceProperties) {
-    // Interface can be "usb", "network", or not present for simulators.
-    return deviceProperties.containsKey('interface') &&
-        (deviceProperties['interface'] as String).toLowerCase() == 'usb';
+  static IOSDeviceInterface _interfaceType(Map<String, dynamic> deviceProperties) {
+    // Interface can be "usb", "network", or "none" for simulators
+    // and unknown future interfaces.
+    if (deviceProperties.containsKey('interface')) {
+      if ((deviceProperties['interface'] as String).toLowerCase() == 'network') {
+        return IOSDeviceInterface.network;
+      } else {
+        return IOSDeviceInterface.usb;
+      }
+    }
+
+    return IOSDeviceInterface.none;
   }
 
   static String _sdkVersion(Map<String, dynamic> deviceProperties) {
diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
index 18e421d..ba4d78d 100644
--- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -72,7 +72,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         sdkVersion: '13.3',
-        cpuArchitecture: DarwinArch.arm64
+        cpuArchitecture: DarwinArch.arm64,
+        interfaceType: IOSDeviceInterface.usb,
       );
     });
 
@@ -87,7 +88,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         cpuArchitecture: DarwinArch.arm64,
-        sdkVersion: '1.0.0'
+        sdkVersion: '1.0.0',
+        interfaceType: IOSDeviceInterface.usb,
       ).majorSdkVersion, 1);
       expect(IOSDevice(
         'device-123',
@@ -99,7 +101,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         cpuArchitecture: DarwinArch.arm64,
-        sdkVersion: '13.1.1'
+        sdkVersion: '13.1.1',
+        interfaceType: IOSDeviceInterface.usb,
       ).majorSdkVersion, 13);
       expect(IOSDevice(
         'device-123',
@@ -111,7 +114,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         cpuArchitecture: DarwinArch.arm64,
-        sdkVersion: '10'
+        sdkVersion: '10',
+        interfaceType: IOSDeviceInterface.usb,
       ).majorSdkVersion, 10);
       expect(IOSDevice(
         'device-123',
@@ -123,7 +127,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         cpuArchitecture: DarwinArch.arm64,
-        sdkVersion: '0'
+        sdkVersion: '0',
+        interfaceType: IOSDeviceInterface.usb,
       ).majorSdkVersion, 0);
       expect(IOSDevice(
         'device-123',
@@ -135,7 +140,8 @@
         iMobileDevice: iMobileDevice,
         name: 'iPhone 1',
         cpuArchitecture: DarwinArch.arm64,
-        sdkVersion: 'bogus'
+        sdkVersion: 'bogus',
+        interfaceType: IOSDeviceInterface.usb,
       ).majorSdkVersion, 0);
     });
 
@@ -154,6 +160,7 @@
               name: 'iPhone 1',
               sdkVersion: '13.3',
               cpuArchitecture: DarwinArch.arm64,
+              interfaceType: IOSDeviceInterface.usb,
             );
           },
           throwsAssertionError,
@@ -237,6 +244,7 @@
           name: 'iPhone 1',
           sdkVersion: '13.3',
           cpuArchitecture: DarwinArch.arm64,
+          interfaceType: IOSDeviceInterface.usb,
         );
         logReader1 = createLogReader(device, appPackage1, mockProcess1);
         logReader2 = createLogReader(device, appPackage2, mockProcess2);
@@ -321,8 +329,9 @@
         logger: logger,
         platform: macPlatform,
         fileSystem: MemoryFileSystem.test(),
+        interfaceType: IOSDeviceInterface.usb,
       );
-      when(mockXcdevice.getAvailableTetheredIOSDevices())
+      when(mockXcdevice.getAvailableIOSDevices())
           .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device]));
 
       final List<Device> devices = await iosDevices.pollingGetDevices();
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart
index ab1a8d5..d8e9214 100644
--- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart
@@ -25,7 +25,7 @@
 };
 
 void main() {
-  testWithoutContext('IOSDevice.installApp calls ios-deploy correctly', () async {
+  testWithoutContext('IOSDevice.installApp calls ios-deploy correctly with USB', () async {
     final FileSystem fileSystem = MemoryFileSystem.test();
     final IOSApp iosApp = PrebuiltIOSApp(
       projectBundleId: 'app',
@@ -47,6 +47,36 @@
     final IOSDevice device = setUpIOSDevice(
       processManager: processManager,
       fileSystem: fileSystem,
+      interfaceType: IOSDeviceInterface.usb,
+    );
+    final bool wasInstalled = await device.installApp(iosApp);
+
+    expect(wasInstalled, true);
+    expect(processManager.hasRemainingExpectations, false);
+  });
+
+  testWithoutContext('IOSDevice.installApp calls ios-deploy correctly with network', () async {
+    final FileSystem fileSystem = MemoryFileSystem.test();
+    final IOSApp iosApp = PrebuiltIOSApp(
+      projectBundleId: 'app',
+      bundleDir: fileSystem.currentDirectory,
+    );
+    final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
+      const FakeCommand(command: <String>[
+        'ios-deploy',
+        '--id',
+        '1234',
+        '--bundle',
+        '/',
+      ], environment: <String, String>{
+        'PATH': '/usr/bin:null',
+        ...kDyLdLibEntry,
+      })
+    ]);
+    final IOSDevice device = setUpIOSDevice(
+      processManager: processManager,
+      fileSystem: fileSystem,
+      interfaceType: IOSDeviceInterface.network,
     );
     final bool wasInstalled = await device.installApp(iosApp);
 
@@ -237,6 +267,7 @@
   @required ProcessManager processManager,
   FileSystem fileSystem,
   Logger logger,
+  IOSDeviceInterface interfaceType,
 }) {
   logger ??= BufferLogger.test();
   final FakePlatform platform = FakePlatform(
@@ -270,6 +301,7 @@
       cache: cache,
     ),
     artifacts: artifacts,
+    interfaceType: interfaceType,
   );
 }
 
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart
index 8c9b1d0..d09ead7 100644
--- a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart
@@ -89,6 +89,7 @@
     sdkVersion: '13.3',
     cpuArchitecture: DarwinArch.arm64,
     artifacts: artifacts,
+    interfaceType: IOSDeviceInterface.usb,
   );
 }
 
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart
index 50e6641..b6a768a 100644
--- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart
@@ -329,6 +329,7 @@
       cache: cache,
     ),
     cpuArchitecture: DarwinArch.arm64,
+    interfaceType: IOSDeviceInterface.usb,
   );
 }
 
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart
index 683abc8..71c859a 100644
--- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart
@@ -398,6 +398,7 @@
       cache: cache,
     ),
     cpuArchitecture: DarwinArch.arm64,
+    interfaceType: IOSDeviceInterface.usb,
   );
 }
 
diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
index 935f34d..b60f9a2 100644
--- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
@@ -99,7 +99,7 @@
         when(processManager.run(<String>['xcrun', 'xcdevice', 'list', '--timeout', '2']))
           .thenThrow(const ProcessException('xcrun', <String>['xcdevice', 'list', '--timeout', '2']));
 
-        expect(await xcdevice.getAvailableTetheredIOSDevices(), isEmpty);
+        expect(await xcdevice.getAvailableIOSDevices(), isEmpty);
       });
 
       testWithoutContext('diagnostics xcdevice fails', () async {
@@ -359,7 +359,7 @@
         testWithoutContext('Xcode not installed', () async {
           when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(false);
 
-          expect(await xcdevice.getAvailableTetheredIOSDevices(), isEmpty);
+          expect(await xcdevice.getAvailableIOSDevices(), isEmpty);
         });
 
         testUsingContext('returns devices', () async {
@@ -466,7 +466,7 @@
             command: <String>['xcrun', 'xcdevice', 'list', '--timeout', '2'],
             stdout: devicesOutput,
           ));
-          final List<IOSDevice> devices = await xcdevice.getAvailableTetheredIOSDevices();
+          final List<IOSDevice> devices = await xcdevice.getAvailableIOSDevices();
           expect(devices, hasLength(3));
           expect(devices[0].id, 'd83d5bc53967baa0ee18626ba87b6254b2ab5418');
           expect(devices[0].name, 'An iPhone (Space Gray)');
@@ -496,7 +496,7 @@
             command: <String>['xcrun', 'xcdevice', 'list', '--timeout', '20'],
             stdout: '[]',
           ));
-          await xcdevice.getAvailableTetheredIOSDevices(timeout: const Duration(seconds: 20));
+          await xcdevice.getAvailableIOSDevices(timeout: const Duration(seconds: 20));
           expect(fakeProcessManager.hasRemainingExpectations, isFalse);
         });
 
@@ -535,7 +535,7 @@
             command: <String>['xcrun', 'xcdevice', 'list', '--timeout', '2'],
             stdout: devicesOutput,
           ));
-          final List<IOSDevice> devices = await xcdevice.getAvailableTetheredIOSDevices();
+          final List<IOSDevice> devices = await xcdevice.getAvailableIOSDevices();
           expect(devices, hasLength(1));
           expect(devices[0].id, '43ad2fda7991b34fe1acbda82f9e2fd3d6ddc9f7');
           expect(fakeProcessManager.hasRemainingExpectations, isFalse);
@@ -583,7 +583,7 @@
             command: <String>['xcrun', 'xcdevice', 'list', '--timeout', '2'],
             stdout: devicesOutput,
           ));
-          final List<IOSDevice> devices = await xcdevice.getAvailableTetheredIOSDevices();
+          final List<IOSDevice> devices = await xcdevice.getAvailableIOSDevices();
           expect(devices[0].cpuArchitecture, DarwinArch.armv7);
           expect(devices[1].cpuArchitecture, DarwinArch.arm64);
           expect(fakeProcessManager.hasRemainingExpectations, isFalse);
@@ -634,7 +634,7 @@
             stdout: devicesOutput,
           ));
 
-          await xcdevice.getAvailableTetheredIOSDevices();
+          await xcdevice.getAvailableIOSDevices();
           final List<String> errors = await xcdevice.getDiagnostics();
           expect(errors, hasLength(1));
           expect(fakeProcessManager.hasRemainingExpectations, isFalse);