Remove usage of ideviceinstaller in favor of ios-deploy (#50772)

diff --git a/dev/devicelab/lib/framework/adb.dart b/dev/devicelab/lib/framework/adb.dart
index 9ca6f86..880b850 100644
--- a/dev/devicelab/lib/framework/adb.dart
+++ b/dev/devicelab/lib/framework/adb.dart
@@ -498,9 +498,7 @@
       'usbmuxd',
       'libplist',
       'openssl',
-      'ideviceinstaller',
       'ios-deploy',
-      'libzip',
     ].map((String packageName) => path.join(getArtifactPath(), packageName)).join(':');
     return <String, String>{'DYLD_LIBRARY_PATH': libPath};
   }
diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart
index cc50882..bfbde09 100644
--- a/packages/flutter_tools/lib/src/cache.dart
+++ b/packages/flutter_tools/lib/src/cache.dart
@@ -1261,9 +1261,7 @@
     'usbmuxd',
     'libplist',
     'openssl',
-    'ideviceinstaller',
     'ios-deploy',
-    'libzip',
   ];
 
   // For unknown reasons, users are getting into bad states where libimobiledevice is
diff --git a/packages/flutter_tools/lib/src/commands/install.dart b/packages/flutter_tools/lib/src/commands/install.dart
index a90b9a9..b196542 100644
--- a/packages/flutter_tools/lib/src/commands/install.dart
+++ b/packages/flutter_tools/lib/src/commands/install.dart
@@ -6,6 +6,7 @@
 
 import '../application_package.dart';
 import '../base/common.dart';
+import '../base/io.dart';
 import '../cache.dart';
 import '../device.dart';
 import '../globals.dart' as globals;
@@ -54,11 +55,15 @@
     return false;
   }
 
-  if (uninstall && await device.isAppInstalled(package)) {
-    globals.printStatus('Uninstalling old version...');
-    if (!await device.uninstallApp(package)) {
-      globals.printError('Warning: uninstalling old version failed');
+  try {
+    if (uninstall && await device.isAppInstalled(package)) {
+      globals.printStatus('Uninstalling old version...');
+      if (!await device.uninstallApp(package)) {
+        globals.printError('Warning: uninstalling old version failed');
+      }
     }
+  } on ProcessException catch (e) {
+    globals.printError('Error accessing device ${device.id}:\n${e.message}');
   }
 
   return device.installApp(package);
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index f159906..922f45c 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -35,7 +35,7 @@
 import 'fuchsia/fuchsia_sdk.dart' show FuchsiaSdk, FuchsiaArtifacts;
 import 'fuchsia/fuchsia_workflow.dart' show FuchsiaWorkflow;
 import 'globals.dart' as globals;
-import 'ios/devices.dart' show IOSDeploy;
+import 'ios/ios_deploy.dart';
 import 'ios/ios_workflow.dart';
 import 'ios/mac.dart';
 import 'ios/simulators.dart';
@@ -125,7 +125,13 @@
       GradleUtils: () => GradleUtils(),
       HotRunnerConfig: () => HotRunnerConfig(),
       IMobileDevice: () => IMobileDevice(),
-      IOSDeploy: () => const IOSDeploy(),
+      IOSDeploy: () => IOSDeploy(
+        artifacts: globals.artifacts,
+        cache: globals.cache,
+        logger: globals.logger,
+        platform: globals.platform,
+        processManager: globals.processManager,
+      ),
       IOSSimulatorUtils: () => IOSSimulatorUtils(),
       IOSWorkflow: () => const IOSWorkflow(),
       KernelCompilerFactory: () => const KernelCompilerFactory(),
diff --git a/packages/flutter_tools/lib/src/globals.dart b/packages/flutter_tools/lib/src/globals.dart
index bea01e8..68cda51 100644
--- a/packages/flutter_tools/lib/src/globals.dart
+++ b/packages/flutter_tools/lib/src/globals.dart
@@ -20,6 +20,7 @@
 import 'base/terminal.dart';
 import 'base/user_messages.dart';
 import 'cache.dart';
+import 'ios/ios_deploy.dart';
 import 'ios/mac.dart';
 import 'macos/xcode.dart';
 import 'persistent_tool_state.dart';
@@ -62,6 +63,7 @@
 AndroidSdk get androidSdk => context.get<AndroidSdk>();
 FlutterVersion get flutterVersion => context.get<FlutterVersion>();
 IMobileDevice get iMobileDevice => context.get<IMobileDevice>();
+IOSDeploy get iosDeploy => context.get<IOSDeploy>();
 UserMessages get userMessages => context.get<UserMessages>();
 Xcode get xcode => context.get<Xcode>();
 
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index e3bbae8..6cdea04 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -11,7 +11,6 @@
 import '../application_package.dart';
 import '../artifacts.dart';
 import '../base/common.dart';
-import '../base/context.dart';
 import '../base/file_system.dart';
 import '../base/io.dart';
 import '../base/logger.dart';
@@ -25,85 +24,11 @@
 import '../project.dart';
 import '../protocol_discovery.dart';
 import '../vmservice.dart';
-import 'code_signing.dart';
 import 'fallback_discovery.dart';
+import 'ios_deploy.dart';
 import 'ios_workflow.dart';
 import 'mac.dart';
 
-class IOSDeploy {
-  const IOSDeploy();
-
-  static IOSDeploy get instance => context.get<IOSDeploy>();
-
-  /// Installs and runs the specified app bundle using ios-deploy, then returns
-  /// the exit code.
-  Future<int> runApp({
-    @required String deviceId,
-    @required String bundlePath,
-    @required List<String> launchArguments,
-  }) async {
-    final String iosDeployPath = globals.artifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios);
-    final List<String> launchCommand = <String>[
-      iosDeployPath,
-      '--id',
-      deviceId,
-      '--bundle',
-      bundlePath,
-      '--no-wifi',
-      '--justlaunch',
-      if (launchArguments.isNotEmpty) ...<String>[
-        '--args',
-        launchArguments.join(' '),
-      ],
-    ];
-
-    // Push /usr/bin to the front of PATH to pick up default system python, package 'six'.
-    //
-    // ios-deploy transitively depends on LLDB.framework, which invokes a
-    // Python script that uses package 'six'. LLDB.framework relies on the
-    // python at the front of the path, which may not include package 'six'.
-    // Ensure that we pick up the system install of python, which does include
-    // it.
-    final Map<String, String> iosDeployEnv = Map<String, String>.from(globals.platform.environment);
-    iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}';
-    iosDeployEnv.addEntries(<MapEntry<String, String>>[globals.cache.dyLdLibEntry]);
-
-    return await processUtils.stream(
-      launchCommand,
-      mapFunction: _monitorInstallationFailure,
-      trace: true,
-      environment: iosDeployEnv,
-    );
-  }
-
-  // Maps stdout line stream. Must return original line.
-  String _monitorInstallationFailure(String stdout) {
-    // Installation issues.
-    if (stdout.contains('Error 0xe8008015') || stdout.contains('Error 0xe8000067')) {
-      globals.printError(noProvisioningProfileInstruction, emphasis: true);
-
-    // Launch issues.
-    } else if (stdout.contains('e80000e2')) {
-      globals.printError('''
-═══════════════════════════════════════════════════════════════════════════════════
-Your device is locked. Unlock your device first before running.
-═══════════════════════════════════════════════════════════════════════════════════''',
-      emphasis: true);
-    } else if (stdout.contains('Error 0xe8000022')) {
-      globals.printError('''
-═══════════════════════════════════════════════════════════════════════════════════
-Error launching app. Try launching from within Xcode via:
-    open ios/Runner.xcworkspace
-
-Your Xcode version may be too old for your iOS version.
-═══════════════════════════════════════════════════════════════════════════════════''',
-      emphasis: true);
-    }
-
-    return stdout;
-  }
-}
-
 class IOSDevices extends PollingDeviceDiscovery {
   IOSDevices() : super('iOS devices');
 
@@ -122,35 +47,38 @@
 
 class IOSDevice extends Device {
   IOSDevice(String id, {
+    @required FileSystem fileSystem,
     @required this.name,
     @required this.cpuArchitecture,
     @required String sdkVersion,
+    @required Platform platform,
+    @required Artifacts artifacts,
+    @required IOSDeploy iosDeploy,
   })
       : _sdkVersion = sdkVersion,
+        _iosDeploy = iosDeploy,
+        _fileSystem = fileSystem,
         super(
           id,
           category: Category.mobile,
           platformType: PlatformType.ios,
           ephemeral: true,
       ) {
-    if (!globals.platform.isMacOS) {
+    if (!platform.isMacOS) {
       assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
       return;
     }
-    _installerPath = globals.artifacts.getArtifactPath(
-      Artifact.ideviceinstaller,
-      platform: TargetPlatform.ios,
-    );
-    _iproxyPath = globals.artifacts.getArtifactPath(
+    _iproxyPath = artifacts.getArtifactPath(
       Artifact.iproxy,
       platform: TargetPlatform.ios,
     );
   }
 
-  String _installerPath;
   String _iproxyPath;
 
   final String _sdkVersion;
+  final IOSDeploy _iosDeploy;
+  final FileSystem _fileSystem;
 
   /// May be 0 if version cannot be parsed.
   int get majorSdkVersion {
@@ -200,19 +128,17 @@
 
   @override
   Future<bool> isAppInstalled(IOSApp app) async {
-    RunResult apps;
+    bool result;
     try {
-      apps = await processUtils.run(
-        <String>[_installerPath, '--list-apps'],
-        throwOnError: true,
-        environment: Map<String, String>.fromEntries(
-          <MapEntry<String, String>>[globals.cache.dyLdLibEntry],
-        ),
+      result = await _iosDeploy.isAppInstalled(
+        bundleId: app.id,
+        deviceId: id,
       );
-    } on ProcessException {
+    } on ProcessException catch (e) {
+      globals.printError(e.message);
       return false;
     }
-    return RegExp(app.id, multiLine: true).hasMatch(apps.stdout);
+    return result;
   }
 
   @override
@@ -220,42 +146,50 @@
 
   @override
   Future<bool> installApp(IOSApp app) async {
-    final Directory bundle = globals.fs.directory(app.deviceBundlePath);
+    final Directory bundle = _fileSystem.directory(app.deviceBundlePath);
     if (!bundle.existsSync()) {
       globals.printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
       return false;
     }
 
+    int installationResult;
     try {
-      await processUtils.run(
-        <String>[_installerPath, '-i', app.deviceBundlePath],
-        throwOnError: true,
-        environment: Map<String, String>.fromEntries(
-          <MapEntry<String, String>>[globals.cache.dyLdLibEntry],
-        ),
+      installationResult = await _iosDeploy.installApp(
+        deviceId: id,
+        bundlePath: bundle.path,
+        launchArguments: <String>[],
       );
-      return true;
-    } on ProcessException catch (error) {
-      globals.printError(error.message);
+    } on ProcessException catch (e) {
+      globals.printError(e.message);
       return false;
     }
+    if (installationResult != 0) {
+      globals.printError('Could not install ${bundle.path} on $id.');
+      globals.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
+      globals.printError('  open ios/Runner.xcworkspace');
+      globals.printError('');
+      return false;
+    }
+    return true;
   }
 
   @override
   Future<bool> uninstallApp(IOSApp app) async {
+    int uninstallationResult;
     try {
-      await processUtils.run(
-        <String>[_installerPath, '-U', app.id],
-        throwOnError: true,
-        environment: Map<String, String>.fromEntries(
-          <MapEntry<String, String>>[globals.cache.dyLdLibEntry],
-        ),
+      uninstallationResult = await _iosDeploy.uninstallApp(
+        deviceId: id,
+        bundleId: app.id,
       );
-      return true;
-    } on ProcessException catch (error) {
-      globals.printError(error.message);
+    } on ProcessException catch (e) {
+      globals.printError(e.message);
       return false;
     }
+    if (uninstallationResult != 0) {
+      globals.printError('Could not uninstall ${app.id} on $id.');
+      return false;
+    }
+    return true;
   }
 
   @override
@@ -301,7 +235,7 @@
     packageId ??= package.id;
 
     // Step 2: Check that the application exists at the specified path.
-    final Directory bundle = globals.fs.directory(package.deviceBundlePath);
+    final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
     if (!bundle.existsSync()) {
       globals.printError('Could not find the built application bundle at ${bundle.path}.');
       return LaunchResult.failed();
@@ -359,13 +293,13 @@
           ipv6: ipv6,
         );
       }
-      final int installationResult = await IOSDeploy.instance.runApp(
+      final int installationResult = await _iosDeploy.runApp(
         deviceId: id,
         bundlePath: bundle.path,
         launchArguments: launchArguments,
       );
       if (installationResult != 0) {
-        globals.printError('Could not install ${bundle.path} on $id.');
+        globals.printError('Could not run ${bundle.path} on $id.');
         globals.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
         globals.printError('  open ios/Runner.xcworkspace');
         globals.printError('');
@@ -395,6 +329,9 @@
         return LaunchResult.failed();
       }
       return LaunchResult.succeeded(observatoryUri: localUri);
+    } on ProcessException catch (e) {
+      globals.printError(e.message);
+      return LaunchResult.failed();
     } finally {
       installStatus.stop();
     }
@@ -450,7 +387,7 @@
 
   @override
   Future<void> dispose() async {
-    _logReaders.forEach((IOSApp application, DeviceLogReader logReader) {
+    _logReaders?.forEach((IOSApp application, DeviceLogReader logReader) {
       logReader.dispose();
     });
     await _portForwarder?.dispose();
diff --git a/packages/flutter_tools/lib/src/ios/ios_deploy.dart b/packages/flutter_tools/lib/src/ios/ios_deploy.dart
new file mode 100644
index 0000000..de0fe59
--- /dev/null
+++ b/packages/flutter_tools/lib/src/ios/ios_deploy.dart
@@ -0,0 +1,187 @@
+// 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 'dart:async';
+
+import 'package:meta/meta.dart';
+import 'package:platform/platform.dart';
+import 'package:process/process.dart';
+
+import '../artifacts.dart';
+import '../base/logger.dart';
+import '../base/process.dart';
+import '../build_info.dart';
+import '../cache.dart';
+import 'code_signing.dart';
+
+// Error message patterns from ios-deploy output
+const String noProvisioningProfileErrorOne = 'Error 0xe8008015';
+const String noProvisioningProfileErrorTwo = 'Error 0xe8000067';
+const String deviceLockedError = 'e80000e2';
+const String unknownAppLaunchError = 'Error 0xe8000022';
+
+class IOSDeploy {
+  IOSDeploy({
+    @required Artifacts artifacts,
+    @required Cache cache,
+    @required Logger logger,
+    @required Platform platform,
+    @required ProcessManager processManager,
+  }) : _platform = platform,
+       _cache = cache,
+       _processUtils = ProcessUtils(processManager: processManager, logger: logger),
+       _logger = logger,
+       _binaryPath = artifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios);
+
+  final Cache _cache;
+  final String _binaryPath;
+  final Logger _logger;
+  final Platform _platform;
+  final ProcessUtils _processUtils;
+
+  Map<String, String> get iosDeployEnv {
+    // Push /usr/bin to the front of PATH to pick up default system python, package 'six'.
+    //
+    // ios-deploy transitively depends on LLDB.framework, which invokes a
+    // Python script that uses package 'six'. LLDB.framework relies on the
+    // python at the front of the path, which may not include package 'six'.
+    // Ensure that we pick up the system install of python, which includes it.
+    final Map<String, String> environment = Map<String, String>.from(_platform.environment);
+    environment['PATH'] = '/usr/bin:${environment['PATH']}';
+    environment.addEntries(<MapEntry<String, String>>[_cache.dyLdLibEntry]);
+    return environment;
+  }
+
+  /// Uninstalls the specified app bundle.
+  ///
+  /// Uses ios-deploy and returns the exit code.
+  Future<int> uninstallApp({
+    @required String deviceId,
+    @required String bundleId,
+  }) async {
+    final List<String> launchCommand = <String>[
+      _binaryPath,
+      '--id',
+      deviceId,
+      '--uninstall_only',
+      '--bundle_id',
+      bundleId,
+    ];
+
+    return _processUtils.stream(
+      launchCommand,
+      mapFunction: _monitorFailure,
+      trace: true,
+      environment: iosDeployEnv,
+    );
+  }
+
+  /// Installs the specified app bundle.
+  ///
+  /// Uses ios-deploy and returns the exit code.
+  Future<int> installApp({
+    @required String deviceId,
+    @required String bundlePath,
+    @required List<String>launchArguments,
+  }) async {
+    final List<String> launchCommand = <String>[
+      _binaryPath,
+      '--id',
+      deviceId,
+      '--bundle',
+      bundlePath,
+      '--no-wifi',
+      if (launchArguments.isNotEmpty) ...<String>[
+        '--args',
+        launchArguments.join(' '),
+      ],
+    ];
+
+    return _processUtils.stream(
+      launchCommand,
+      mapFunction: _monitorFailure,
+      trace: true,
+      environment: iosDeployEnv,
+    );
+  }
+
+  /// Installs and then runs the specified app bundle.
+  ///
+  /// Uses ios-deploy and returns the exit code.
+  Future<int> runApp({
+    @required String deviceId,
+    @required String bundlePath,
+    @required List<String> launchArguments,
+  }) async {
+    final List<String> launchCommand = <String>[
+      _binaryPath,
+      '--id',
+      deviceId,
+      '--bundle',
+      bundlePath,
+      '--no-wifi',
+      '--justlaunch',
+      if (launchArguments.isNotEmpty) ...<String>[
+        '--args',
+        launchArguments.join(' '),
+      ],
+    ];
+
+    return _processUtils.stream(
+      launchCommand,
+      mapFunction: _monitorFailure,
+      trace: true,
+      environment: iosDeployEnv,
+    );
+  }
+
+  Future<bool> isAppInstalled({
+    @required String bundleId,
+    @required String deviceId,
+  }) async {
+    final List<String> launchCommand = <String>[
+      _binaryPath,
+      '--id',
+      deviceId,
+      '--exists',
+      '--bundle_id',
+      bundleId,
+    ];
+    final RunResult result = await _processUtils.run(
+      launchCommand,
+      environment: iosDeployEnv,
+    );
+    if (result.exitCode != 0) {
+      return false;
+    }
+    return result.stdout.contains(bundleId);
+  }
+
+  // Maps stdout line stream. Must return original line.
+  String _monitorFailure(String stdout) {
+    // Installation issues.
+    if (stdout.contains(noProvisioningProfileErrorOne) || stdout.contains(noProvisioningProfileErrorTwo)) {
+      _logger.printError(noProvisioningProfileInstruction, emphasis: true);
+
+    // Launch issues.
+    } else if (stdout.contains(deviceLockedError)) {
+      _logger.printError('''
+═══════════════════════════════════════════════════════════════════════════════════
+Your device is locked. Unlock your device first before running.
+═══════════════════════════════════════════════════════════════════════════════════''',
+      emphasis: true);
+    } else if (stdout.contains(unknownAppLaunchError)) {
+      _logger.printError('''
+═══════════════════════════════════════════════════════════════════════════════════
+Error launching app. Try launching from within Xcode via:
+    open ios/Runner.xcworkspace
+
+Your Xcode version may be too old for your iOS version.
+═══════════════════════════════════════════════════════════════════════════════════''',
+      emphasis: true);
+    }
+
+    return stdout;
+  }
+}
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index c4dcd78..6e4971a 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -91,7 +91,6 @@
     return XcodeBuildResult(success: false);
   }
 
-
   final XcodeProjectInfo projectInfo = await xcodeProjectInterpreter.getInfo(app.project.hostAppRoot.path);
   if (!projectInfo.targets.contains('Runner')) {
     globals.printError('The Xcode project does not define target "Runner" which is needed by Flutter tooling.');
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
index 81d2656..55d22dc 100644
--- a/packages/flutter_tools/lib/src/macos/xcode.dart
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -15,6 +15,7 @@
 import '../base/process.dart';
 import '../build_info.dart';
 import '../convert.dart';
+import '../globals.dart' as globals;
 import '../ios/devices.dart';
 import '../ios/xcodeproj.dart';
 import '../reporting/reporting.dart';
@@ -349,6 +350,10 @@
         name: device['name'] as String,
         cpuArchitecture: _cpuArchitecture(deviceProperties),
         sdkVersion: _sdkVersion(deviceProperties),
+        artifacts: globals.artifacts,
+        fileSystem: globals.fs,
+        iosDeploy: globals.iosDeploy,
+        platform: globals.platform,
       ));
     }
     return devices;
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 b30b9ee..6ec864d 100644
--- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -12,6 +12,7 @@
 import 'package:flutter_tools/src/artifacts.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:flutter_tools/src/base/io.dart';
+import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/commands/create.dart';
@@ -19,6 +20,7 @@
 import 'package:flutter_tools/src/doctor.dart';
 import 'package:flutter_tools/src/ios/devices.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/ios/ios_deploy.dart';
 import 'package:flutter_tools/src/ios/ios_workflow.dart';
 import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:flutter_tools/src/mdns_discovery.dart';
@@ -40,19 +42,23 @@
 class MockApplicationPackage extends Mock implements ApplicationPackage {}
 class MockArtifacts extends Mock implements Artifacts {}
 class MockCache extends Mock implements Cache {}
+class MockDevicePortForwarder extends Mock implements DevicePortForwarder {}
 class MockDirectory extends Mock implements Directory {}
+class MockFile extends Mock implements File {}
 class MockFileSystem extends Mock implements FileSystem {}
 class MockForwardedPort extends Mock implements ForwardedPort {}
 class MockIMobileDevice extends Mock implements IMobileDevice {}
 class MockIOSDeploy extends Mock implements IOSDeploy {}
-class MockDevicePortForwarder extends Mock implements DevicePortForwarder {}
+class MockLogger extends Mock implements Logger {}
 class MockMDnsObservatoryDiscovery extends Mock implements MDnsObservatoryDiscovery {}
 class MockMDnsObservatoryDiscoveryResult extends Mock implements MDnsObservatoryDiscoveryResult {}
-class MockXcode extends Mock implements Xcode {}
-class MockFile extends Mock implements File {}
+class MockPlatform extends Mock implements Platform {}
 class MockPortForwarder extends Mock implements DevicePortForwarder {}
+// src/mocks.dart imports `MockProcessManager` which implements some methods, this is a full mock
+class FullMockProcessManager extends Mock implements ProcessManager {}
 class MockUsage extends Mock implements Usage {}
 class MockXcdevice extends Mock implements XCDevice {}
+class MockXcode extends Mock implements Xcode {}
 
 void main() {
   final FakePlatform macPlatform = FakePlatform.fromPlatform(const LocalPlatform());
@@ -64,34 +70,187 @@
 
   group('IOSDevice', () {
     final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform];
+    Artifacts mockArtifacts;
+    MockCache mockCache;
+    MockLogger mockLogger;
+    IOSDeploy iosDeploy;
+    FileSystem mockFileSystem;
 
-    testUsingContext('successfully instantiates on Mac OS', () {
-      IOSDevice('device-123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
-    }, overrides: <Type, Generator>{
-      Platform: () => macPlatform,
+    setUp(() {
+      mockArtifacts = MockArtifacts();
+      mockCache = MockCache();
+      const MapEntry<String, String> dyLdLibEntry = MapEntry<String, String>('DYLD_LIBRARY_PATH', '/path/to/libs');
+      when(mockCache.dyLdLibEntry).thenReturn(dyLdLibEntry);
+      mockFileSystem = MockFileSystem();
+      mockLogger = MockLogger();
+      iosDeploy = IOSDeploy(
+        artifacts: mockArtifacts,
+        cache: mockCache,
+        logger: mockLogger,
+        platform: macPlatform,
+        processManager: FakeProcessManager.any(),
+      );
     });
 
-    testUsingContext('parses major version', () {
-      expect(IOSDevice('device-123', name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '1.0.0').majorSdkVersion, 1);
-      expect(IOSDevice('device-123', name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.1.1').majorSdkVersion, 13);
-      expect(IOSDevice('device-123', name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '10').majorSdkVersion, 10);
-      expect(IOSDevice('device-123', name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '0').majorSdkVersion, 0);
-      expect(IOSDevice('device-123', name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: 'bogus').majorSdkVersion, 0);
-    }, overrides: <Type, Generator>{
-      Platform: () => macPlatform,
+    testWithoutContext('successfully instantiates on Mac OS', () {
+      IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64
+      );
+    });
+
+    testWithoutContext('parses major version', () {
+      expect(IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        cpuArchitecture: DarwinArch.arm64,
+        sdkVersion: '1.0.0'
+      ).majorSdkVersion, 1);
+      expect(IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        cpuArchitecture: DarwinArch.arm64,
+        sdkVersion: '13.1.1'
+      ).majorSdkVersion, 13);
+      expect(IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        cpuArchitecture: DarwinArch.arm64,
+        sdkVersion: '10'
+      ).majorSdkVersion, 10);
+      expect(IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        cpuArchitecture: DarwinArch.arm64,
+        sdkVersion: '0'
+      ).majorSdkVersion, 0);
+      expect(IOSDevice(
+        'device-123',
+        artifacts: mockArtifacts,
+        fileSystem: mockFileSystem,
+        platform: macPlatform,
+        iosDeploy: iosDeploy,
+        name: 'iPhone 1',
+        cpuArchitecture: DarwinArch.arm64,
+        sdkVersion: 'bogus'
+      ).majorSdkVersion, 0);
     });
 
     for (final Platform platform in unsupportedPlatforms) {
-      testUsingContext('throws UnsupportedError exception if instantiated on ${platform.operatingSystem}', () {
+      testWithoutContext('throws UnsupportedError exception if instantiated on ${platform.operatingSystem}', () {
         expect(
-          () { IOSDevice('device-123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64); },
+          () {
+            IOSDevice(
+              'device-123',
+              artifacts: mockArtifacts,
+              fileSystem: mockFileSystem,
+              platform: platform,
+              iosDeploy: iosDeploy,
+              name: 'iPhone 1',
+              sdkVersion: '13.3',
+              cpuArchitecture: DarwinArch.arm64,
+            );
+          },
           throwsAssertionError,
         );
-      }, overrides: <Type, Generator>{
-        Platform: () => platform,
       });
     }
 
+    group('ios-deploy wrappers', () {
+      const String appId = '789';
+      IOSDevice device;
+      IOSDeploy iosDeploy;
+      FullMockProcessManager mockProcessManager;
+
+      setUp(() {
+        mockProcessManager = FullMockProcessManager();
+        iosDeploy = IOSDeploy(
+          artifacts: mockArtifacts,
+          cache: mockCache,
+          logger: mockLogger,
+          platform: macPlatform,
+          processManager: mockProcessManager,
+        );
+
+        device = IOSDevice(
+          'device-123',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: iosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
+      });
+
+      testUsingContext('isAppInstalled() catches ProcessException from ios-deploy', () async {
+        final MockIOSApp mockApp = MockIOSApp();
+        when(mockApp.id).thenReturn(appId);
+        when(mockProcessManager.run(
+          any,
+          workingDirectory: anyNamed('workingDirectory'),
+          environment: anyNamed('environment'),
+        )).thenThrow(const ProcessException('ios-deploy', <String>[]));
+
+        final bool result = await device.isAppInstalled(mockApp);
+        expect(result, false);
+      });
+
+      testUsingContext('installApp() catches ProcessException from ios-deploy', () async {
+        const String bundlePath = '/path/to/bundle';
+        final MockIOSApp mockApp = MockIOSApp();
+        when(mockApp.id).thenReturn(appId);
+        when(mockApp.deviceBundlePath).thenReturn(bundlePath);
+        final MockDirectory mockDirectory = MockDirectory();
+        when(mockFileSystem.directory(bundlePath)).thenReturn(mockDirectory);
+        when(mockDirectory.existsSync()).thenReturn(true);
+        when(mockProcessManager.start(
+          any,
+          workingDirectory: anyNamed('workingDirectory'),
+          environment: anyNamed('environment'),
+        )).thenThrow(const ProcessException('ios-deploy', <String>[]));
+
+        final bool result = await device.installApp(mockApp);
+        expect(result, false);
+      });
+
+      testUsingContext('uninstallApp() catches ProcessException from ios-deploy', () async {
+        final MockIOSApp mockApp = MockIOSApp();
+        when(mockApp.id).thenReturn(appId);
+        when(mockProcessManager.start(
+          any,
+          workingDirectory: anyNamed('workingDirectory'),
+          environment: anyNamed('environment'),
+        )).thenThrow(const ProcessException('ios-deploy', <String>[]));
+
+        final bool result = await device.uninstallApp(mockApp);
+        expect(result, false);
+      });
+    });
+
     group('.dispose()', () {
       IOSDevice device;
       MockIOSApp appPackage1;
@@ -103,6 +262,11 @@
       MockProcess mockProcess3;
       IOSDevicePortForwarder portForwarder;
       ForwardedPort forwardedPort;
+      Artifacts mockArtifacts;
+      MockCache mockCache;
+      MockLogger mockLogger;
+      IOSDeploy iosDeploy;
+      FileSystem mockFileSystem;
 
       IOSDevicePortForwarder createPortForwarder(
           ForwardedPort forwardedPort,
@@ -130,10 +294,29 @@
         mockProcess2 = MockProcess();
         mockProcess3 = MockProcess();
         forwardedPort = ForwardedPort.withContext(123, 456, mockProcess3);
+        mockArtifacts = MockArtifacts();
+        mockCache = MockCache();
+        mockFileSystem = MockFileSystem();
+        iosDeploy = IOSDeploy(
+          artifacts: mockArtifacts,
+          cache: mockCache,
+          logger: mockLogger,
+          platform: macPlatform,
+          processManager: FakeProcessManager.any(),
+        );
       });
 
-      testUsingContext(' kills all log readers & port forwarders', () async {
-        device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+      testWithoutContext('kills all log readers & port forwarders', () async {
+        device = IOSDevice(
+          '123',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: iosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
         logReader1 = createLogReader(device, appPackage1, mockProcess1);
         logReader2 = createLogReader(device, appPackage2, mockProcess2);
         portForwarder = createPortForwarder(forwardedPort, device);
@@ -146,8 +329,6 @@
         verify(mockProcess1.kill());
         verify(mockProcess2.kill());
         verify(mockProcess3.kill());
-      }, overrides: <Type, Generator>{
-        Platform: () => macPlatform,
       });
     });
 
@@ -156,6 +337,7 @@
       MockArtifacts mockArtifacts;
       MockCache mockCache;
       MockFileSystem mockFileSystem;
+      MockPlatform mockPlatform;
       MockProcessManager mockProcessManager;
       MockDeviceLogReader mockLogReader;
       MockMDnsObservatoryDiscovery mockMDnsObservatoryDiscovery;
@@ -188,6 +370,8 @@
         mockCache = MockCache();
         when(mockCache.dyLdLibEntry).thenReturn(libraryEntry);
         mockFileSystem = MockFileSystem();
+        mockPlatform = MockPlatform();
+        when(mockPlatform.isMacOS).thenReturn(true);
         mockMDnsObservatoryDiscovery = MockMDnsObservatoryDiscovery();
         mockProcessManager = MockProcessManager();
         mockLogReader = MockDeviceLogReader();
@@ -254,17 +438,43 @@
       });
 
       testUsingContext('disposing device disposes the portForwarder', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+        final IOSDevice device = IOSDevice(
+          '123',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: iosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
         device.portForwarder = mockPortForwarder;
         device.setLogReader(mockApp, mockLogReader);
         await device.dispose();
         verify(mockPortForwarder.dispose()).called(1);
-      }, overrides: <Type, Generator>{
-        Platform: () => macPlatform,
       });
 
-      testUsingContext(' succeeds in debug mode via mDNS', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+      testUsingContext('succeeds in debug mode via mDNS', () async {
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: mockIosDeploy,
+          cpuArchitecture: DarwinArch.arm64,
+        );
+        when(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: <String>[],
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
+        when(mockIosDeploy.runApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
         device.portForwarder = mockPortForwarder;
         device.setLogReader(mockApp, mockLogReader);
         final Uri uri = Uri(
@@ -298,10 +508,19 @@
       // By default, the .forward() method will try every port between 1024
       // and 65535; this test verifies we are killing iproxy processes when
       // we timeout on a port
-      testUsingContext(' .forward() will kill iproxy processes before invoking a second', () async {
+      testUsingContext('.forward() will kill iproxy processes before invoking a second', () async {
         const String deviceId = '123';
         const int devicePort = 456;
-        final IOSDevice device = IOSDevice(deviceId, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+        final IOSDevice device = IOSDevice(
+          deviceId,
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: iosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
         final IOSDevicePortForwarder portForwarder = IOSDevicePortForwarder(device);
         bool firstRun = true;
         final MockProcess successProcess = MockProcess(
@@ -334,8 +553,25 @@
         Usage: () => mockUsage,
       });
 
-      testUsingContext(' succeeds in debug mode when mDNS fails by falling back to manual protocol discovery', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+      testUsingContext('succeeds in debug mode when mDNS fails by falling back to manual protocol discovery', () async {
+        final IOSDevice device = IOSDevice(
+          '123',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: mockIosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
+        when(
+          mockIosDeploy.installApp(deviceId: device.id, bundlePath: anyNamed('bundlePath'), launchArguments: <String>[])
+        ).thenAnswer((Invocation invocation) => Future<int>.value(0));
+        when(mockIosDeploy.runApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
         device.portForwarder = mockPortForwarder;
         device.setLogReader(mockApp, mockLogReader);
         // Now that the reader is used, start writing messages to it.
@@ -351,10 +587,10 @@
           debuggingOptions: DebuggingOptions.enabled(const BuildInfo(BuildMode.debug, null, treeShakeIcons: false)),
           platformArgs: <String, dynamic>{},
         );
-        verify(mockUsage.sendEvent('ios-handshake', 'mdns-failure')).called(1);
-        verify(mockUsage.sendEvent('ios-handshake', 'fallback-success')).called(1);
         expect(launchResult.started, isTrue);
         expect(launchResult.hasObservatory, isTrue);
+        verify(mockUsage.sendEvent('ios-handshake', 'mdns-failure')).called(1);
+        verify(mockUsage.sendEvent('ios-handshake', 'fallback-success')).called(1);
         expect(await device.stopApp(mockApp), isFalse);
       }, overrides: <Type, Generator>{
         Artifacts: () => mockArtifacts,
@@ -366,8 +602,27 @@
         Usage: () => mockUsage,
       });
 
-      testUsingContext(' fails in debug mode when mDNS fails and when Observatory URI is malformed', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+      testUsingContext('fails in debug mode when mDNS fails and when Observatory URI is malformed', () async {
+        final IOSDevice device = IOSDevice(
+          '123',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: mockIosDeploy,
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+        );
+        when(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: <String>[],
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
+        when(mockIosDeploy.runApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
         device.portForwarder = mockPortForwarder;
         device.setLogReader(mockApp, mockLogReader);
 
@@ -384,6 +639,8 @@
             debuggingOptions: DebuggingOptions.enabled(const BuildInfo(BuildMode.debug, null, treeShakeIcons: false)),
             platformArgs: <String, dynamic>{},
         );
+        expect(launchResult.started, isFalse);
+        expect(launchResult.hasObservatory, isFalse);
         verify(mockUsage.sendEvent(
           'ios-handshake',
           'failure',
@@ -392,8 +649,6 @@
         )).called(1);
         verify(mockUsage.sendEvent('ios-handshake', 'mdns-failure')).called(1);
         verify(mockUsage.sendEvent('ios-handshake', 'fallback-failure')).called(1);
-        expect(launchResult.started, isFalse);
-        expect(launchResult.hasObservatory, isFalse);
       }, overrides: <Type, Generator>{
         Artifacts: () => mockArtifacts,
         Cache: () => mockCache,
@@ -405,25 +660,57 @@
       });
 
       testUsingContext('succeeds in release mode', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          fileSystem: mockFileSystem,
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+          platform: mockPlatform,
+          artifacts: mockArtifacts,
+          iosDeploy: mockIosDeploy,
+        );
+        when(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: <String>[],
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
+        when(mockIosDeploy.runApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
         final LaunchResult launchResult = await device.startApp(mockApp,
           prebuiltApplication: true,
           debuggingOptions: DebuggingOptions.disabled(const BuildInfo(BuildMode.release, null, treeShakeIcons: false)),
           platformArgs: <String, dynamic>{},
         );
+        verify(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: <String>[],
+        ));
+        verify(mockIosDeploy.runApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        ));
         expect(launchResult.started, isTrue);
         expect(launchResult.hasObservatory, isFalse);
         expect(await device.stopApp(mockApp), isFalse);
-      }, overrides: <Type, Generator>{
-        Artifacts: () => mockArtifacts,
-        Cache: () => mockCache,
-        FileSystem: () => mockFileSystem,
-        Platform: () => macPlatform,
-        ProcessManager: () => mockProcessManager,
       });
 
       testUsingContext('succeeds with --cache-sksl', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: mockIosDeploy,
+          cpuArchitecture: DarwinArch.arm64,
+        );
         device.setLogReader(mockApp, mockLogReader);
         final Uri uri = Uri(
           scheme: 'http',
@@ -443,6 +730,11 @@
           args = inv.namedArguments[const Symbol('launchArguments')] as List<String>;
           return Future<int>.value(0);
         });
+        when(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
 
         final LaunchResult launchResult = await device.startApp(mockApp,
           prebuiltApplication: true,
@@ -467,7 +759,16 @@
       });
 
       testUsingContext('succeeds with --device-vmservice-port', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          sdkVersion: '13.3',
+          artifacts: mockArtifacts,
+          fileSystem: mockFileSystem,
+          platform: macPlatform,
+          iosDeploy: mockIosDeploy,
+          cpuArchitecture: DarwinArch.arm64,
+        );
         device.setLogReader(mockApp, mockLogReader);
         final Uri uri = Uri(
           scheme: 'http',
@@ -488,6 +789,11 @@
           return Future<int>.value(0);
         });
 
+        when(mockIosDeploy.installApp(
+          deviceId: device.id,
+          bundlePath: anyNamed('bundlePath'),
+          launchArguments: anyNamed('launchArguments'),
+        )).thenAnswer((Invocation invocation) => Future<int>.value(0));
         final LaunchResult launchResult = await device.startApp(mockApp,
           prebuiltApplication: true,
           debuggingOptions: DebuggingOptions.enabled(
@@ -581,7 +887,16 @@
 
           final IOSApp app = await AbsoluteBuildableIOSApp.fromProject(
             FlutterProject.fromDirectory(projectDir).ios);
-          final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+          final IOSDevice device = IOSDevice(
+            '123',
+            name: 'iPhone 1',
+            sdkVersion: '13.3',
+            artifacts: mockArtifacts,
+            fileSystem: globals.fs,
+            platform: macPlatform,
+            iosDeploy: mockIosDeploy,
+            cpuArchitecture: DarwinArch.arm64,
+          );
 
           // Pre-create the expected build products.
           targetBuildDir.createSync(recursive: true);
@@ -611,7 +926,6 @@
         }, overrides: <Type, Generator>{
           DoctorValidatorsProvider: () => FakeIosDoctorProvider(),
           IMobileDevice: () => mockIMobileDevice,
-          IOSDeploy: () => mockIosDeploy,
           Platform: () => macPlatform,
           ProcessManager: () => mockProcessManager,
         });
@@ -658,102 +972,154 @@
     });
 
     group('Process calls', () {
+      const String bundlePath = '/path/to/bundle';
+      FileSystem fs;
+      MockDirectory directory;
       MockIOSApp mockApp;
       MockArtifacts mockArtifacts;
       MockCache mockCache;
       MockFileSystem mockFileSystem;
-      MockProcessManager mockProcessManager;
-      const String installerPath = '/path/to/ideviceinstaller';
+      MockLogger mockLogger;
+      MockPlatform mockPlatform;
+      FullMockProcessManager mockProcessManager;
+      const String iosDeployPath = '/path/to/ios-deploy';
       const String appId = '789';
       const MapEntry<String, String> libraryEntry = MapEntry<String, String>(
         'DYLD_LIBRARY_PATH',
         '/path/to/libraries',
       );
-      final Map<String, String> env = Map<String, String>.fromEntries(
-          <MapEntry<String, String>>[libraryEntry]
-      );
+      IOSDeploy iosDeploy;
 
       setUp(() {
+        mockFileSystem = MockFileSystem();
+        directory = MockDirectory();
+        when(mockFileSystem.directory(bundlePath)).thenReturn(directory);
+
         mockApp = MockIOSApp();
+        when(mockApp.id).thenReturn(appId);
+        when(mockApp.deviceBundlePath).thenReturn(bundlePath);
+        when(directory.existsSync()).thenReturn(true);
+        when(directory.path).thenReturn(bundlePath);
+
         mockArtifacts = MockArtifacts();
         mockCache = MockCache();
+        mockLogger = MockLogger();
+        mockPlatform = MockPlatform();
+        when(mockPlatform.environment).thenReturn(<String, String>{});
+        when(mockPlatform.isMacOS).thenReturn(true);
+        mockProcessManager = FullMockProcessManager();
+        when(
+            mockArtifacts.getArtifactPath(
+                Artifact.iosDeploy,
+                platform: anyNamed('platform'),
+            ),
+        ).thenReturn(iosDeployPath);
+        iosDeploy = IOSDeploy(
+          artifacts: mockArtifacts,
+          cache: mockCache,
+          logger: mockLogger,
+          platform: mockPlatform,
+          processManager: mockProcessManager,
+        );
         when(mockCache.dyLdLibEntry).thenReturn(libraryEntry);
         mockFileSystem = MockFileSystem();
         final MemoryFileSystem memoryFileSystem = MemoryFileSystem();
         when(mockFileSystem.currentDirectory)
           .thenReturn(memoryFileSystem.currentDirectory);
-        mockProcessManager = MockProcessManager();
-        when(
-            mockArtifacts.getArtifactPath(
-                Artifact.ideviceinstaller,
-                platform: anyNamed('platform'),
-            ),
-        ).thenReturn(installerPath);
       });
 
-      testUsingContext('installApp() invokes process with correct environment', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
-        const String bundlePath = '/path/to/bundle';
-        final List<String> args = <String>[installerPath, '-i', bundlePath];
-        when(mockApp.deviceBundlePath).thenReturn(bundlePath);
-        final MockDirectory directory = MockDirectory();
+      testWithoutContext('installApp() calls ios-deploy', () async {
         when(mockFileSystem.directory(bundlePath)).thenReturn(directory);
-        when(directory.existsSync()).thenReturn(true);
-        when(mockProcessManager.run(args, environment: env))
-            .thenAnswer(
-                (_) => Future<ProcessResult>.value(ProcessResult(1, 0, '', ''))
-            );
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          fileSystem: mockFileSystem,
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+          platform: mockPlatform,
+          artifacts: mockArtifacts,
+          iosDeploy: iosDeploy,
+        );
+        final List<String> args = <String>[
+          iosDeployPath,
+          '--id',
+          device.id,
+          '--bundle',
+          bundlePath,
+          '--no-wifi',
+        ];
+        when(mockProcessManager.start(any, workingDirectory: anyNamed('workingDirectory'), environment: anyNamed('environment'))).
+            thenAnswer((Invocation invocation) {
+              return Future<Process>.value(createMockProcess());
+            });
+
         await device.installApp(mockApp);
-        verify(mockProcessManager.run(args, environment: env));
-      }, overrides: <Type, Generator>{
-        Artifacts: () => mockArtifacts,
-        Cache: () => mockCache,
-        FileSystem: () => mockFileSystem,
-        Platform: () => macPlatform,
-        ProcessManager: () => mockProcessManager,
+
+        final List<String> invocationArguments = verify(mockProcessManager.start(
+          captureAny,
+          workingDirectory: anyNamed('workingDirectory'),
+          environment: anyNamed('environment'),
+        )).captured.first as List<String>;
+        expect(invocationArguments, args);
       });
 
-      testUsingContext('isAppInstalled() invokes process with correct environment', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
-        final List<String> args = <String>[installerPath, '--list-apps'];
-        when(mockProcessManager.run(args, environment: env))
-            .thenAnswer(
-                (_) => Future<ProcessResult>.value(ProcessResult(1, 0, '', ''))
-            );
-        when(mockApp.id).thenReturn(appId);
-        await device.isAppInstalled(mockApp);
-        verify(mockProcessManager.run(args, environment: env));
-      }, overrides: <Type, Generator>{
-        Artifacts: () => mockArtifacts,
-        Cache: () => mockCache,
-        Platform: () => macPlatform,
-        ProcessManager: () => mockProcessManager,
-      });
-
-      testUsingContext('uninstallApp() invokes process with correct environment', () async {
-        final IOSDevice device = IOSDevice('123', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
-        final List<String> args = <String>[installerPath, '-U', appId];
-        when(mockApp.id).thenReturn(appId);
-        when(mockProcessManager.run(args, environment: env))
-            .thenAnswer(
-                (_) => Future<ProcessResult>.value(ProcessResult(1, 0, '', ''))
-            );
+      testWithoutContext('uninstallApp() calls ios-deploy', () async {
+        final IOSDevice device = IOSDevice(
+          '123',
+          name: 'iPhone 1',
+          fileSystem: fs,
+          sdkVersion: '13.3',
+          cpuArchitecture: DarwinArch.arm64,
+          platform: mockPlatform,
+          artifacts: mockArtifacts,
+          iosDeploy: iosDeploy,
+        );
+        final List<String> args = <String>[
+          iosDeployPath,
+          '--id',
+          device.id,
+          '--uninstall_only',
+          '--bundle_id',
+          appId,
+        ];
+        when(mockProcessManager.start(args, workingDirectory: anyNamed('workingDirectory'), environment: anyNamed('environment'))).
+            thenAnswer((Invocation invocation) {
+              return Future<Process>.value(createMockProcess());
+            });
         await device.uninstallApp(mockApp);
-        verify(mockProcessManager.run(args, environment: env));
-      }, overrides: <Type, Generator>{
-        Artifacts: () => mockArtifacts,
-        Cache: () => mockCache,
-        Platform: () => macPlatform,
-        ProcessManager: () => mockProcessManager,
+        final List<String> invocationArguments = verify(mockProcessManager.start(
+          captureAny,
+          workingDirectory: anyNamed('workingDirectory'),
+          environment: anyNamed('environment'),
+        )).captured.first as List<String>;
+        expect(invocationArguments, args);
       });
     });
   });
 
   group('getAttachedDevices', () {
     MockXcdevice mockXcdevice;
+    MockArtifacts mockArtifacts;
+    MockCache mockCache;
+    MockFileSystem mockFileSystem;
+    MockLogger mockLogger;
+    FullMockProcessManager mockProcessManager;
+    IOSDeploy iosDeploy;
 
     setUp(() {
       mockXcdevice = MockXcdevice();
+      mockArtifacts = MockArtifacts();
+      mockCache = MockCache();
+      mockLogger = MockLogger();
+      mockFileSystem = MockFileSystem();
+      mockProcessManager = FullMockProcessManager();
+      iosDeploy = IOSDeploy(
+        artifacts: mockArtifacts,
+        cache: mockCache,
+        logger: mockLogger,
+        platform: macPlatform,
+        processManager: mockProcessManager,
+      );
     });
 
     final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform];
@@ -767,17 +1133,25 @@
       });
     }
 
-    testUsingContext('returns attached devices', () async {
+    testWithoutContext('returns attached devices', () async {
       when(mockXcdevice.isInstalled).thenReturn(true);
-      final IOSDevice device = IOSDevice('d83d5bc53967baa0ee18626ba87b6254b2ab5418', name: 'Paired iPhone', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64);
+
+      final IOSDevice device = IOSDevice(
+        'd83d5bc53967baa0ee18626ba87b6254b2ab5418',
+        name: 'Paired iPhone',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64,
+        artifacts: mockArtifacts,
+        iosDeploy: iosDeploy,
+        platform: macPlatform,
+        fileSystem: mockFileSystem,
+      );
       when(mockXcdevice.getAvailableTetheredIOSDevices())
           .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device]));
 
       final List<IOSDevice> devices = await IOSDevice.getAttachedDevices(macPlatform, mockXcdevice);
       expect(devices, hasLength(1));
       expect(identical(devices.first, device), isTrue);
-    }, overrides: <Type, Generator>{
-      Platform: () => macPlatform,
     });
   });
 
@@ -820,13 +1194,32 @@
       expect(decoded, r'I \M-b\M^O syslog!');
     });
   });
+
   group('logging', () {
     MockIMobileDevice mockIMobileDevice;
     MockIosProject mockIosProject;
+    MockArtifacts mockArtifacts;
+    MockCache mockCache;
+    MockFileSystem mockFileSystem;
+    MockLogger mockLogger;
+    FullMockProcessManager mockProcessManager;
+    IOSDeploy iosDeploy;
 
     setUp(() {
       mockIMobileDevice = MockIMobileDevice();
       mockIosProject = MockIosProject();
+      mockArtifacts = MockArtifacts();
+      mockCache = MockCache();
+      mockLogger = MockLogger();
+      mockFileSystem = MockFileSystem();
+      mockProcessManager = FullMockProcessManager();
+      iosDeploy = IOSDeploy(
+        artifacts: mockArtifacts,
+        cache: mockCache,
+        logger: mockLogger,
+        platform: macPlatform,
+        processManager: mockProcessManager,
+      );
     });
 
     testUsingContext('suppresses non-Flutter lines from output', () async {
@@ -843,7 +1236,16 @@
         return Future<Process>.value(mockProcess);
       });
 
-      final IOSDevice device = IOSDevice('123456', name: 'iPhone 1', sdkVersion: '10.3', cpuArchitecture: DarwinArch.arm64);
+      final IOSDevice device = IOSDevice(
+        '123456',
+        name: 'iPhone 1',
+        sdkVersion: '10.3',
+        cpuArchitecture: DarwinArch.arm64,
+        artifacts: mockArtifacts,
+        iosDeploy: iosDeploy,
+        platform: macPlatform,
+        fileSystem: mockFileSystem,
+      );
       final DeviceLogReader logReader = device.getLogReader(
         app: await BuildableIOSApp.fromProject(mockIosProject),
       );
@@ -869,7 +1271,16 @@
         return Future<Process>.value(mockProcess);
       });
 
-      final IOSDevice device = IOSDevice('123456', name: 'iPhone 1', sdkVersion: '10.3', cpuArchitecture: DarwinArch.arm64);
+      final IOSDevice device = IOSDevice(
+        '123456',
+        name: 'iPhone 1',
+        sdkVersion: '10.3',
+        cpuArchitecture: DarwinArch.arm64,
+        artifacts: mockArtifacts,
+        iosDeploy: iosDeploy,
+        platform: macPlatform,
+        fileSystem: mockFileSystem,
+      );
       final DeviceLogReader logReader = device.getLogReader(
         app: await BuildableIOSApp.fromProject(mockIosProject),
       );
@@ -887,47 +1298,97 @@
       Platform: () => macPlatform,
     });
   });
-  testUsingContext('IOSDevice.isSupportedForProject is true on module project', () async {
-    globals.fs.file('pubspec.yaml')
-      ..createSync()
-      ..writeAsStringSync(r'''
-name: example
 
-flutter:
-  module: {}
-''');
-    globals.fs.file('.packages').createSync();
-    final FlutterProject flutterProject = FlutterProject.current();
+  group('isSupportedForProject', () {
+    Artifacts mockArtifacts;
+    MockCache mockCache;
+    MockLogger mockLogger;
+    IOSDeploy iosDeploy;
 
-    expect(IOSDevice('test', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64).isSupportedForProject(flutterProject), true);
-  }, overrides: <Type, Generator>{
-    FileSystem: () => MemoryFileSystem(),
-    ProcessManager: () => FakeProcessManager.any(),
-    Platform: () => macPlatform,
-  });
-  testUsingContext('IOSDevice.isSupportedForProject is true with editable host app', () async {
-    globals.fs.file('pubspec.yaml').createSync();
-    globals.fs.file('.packages').createSync();
-    globals.fs.directory('ios').createSync();
-    final FlutterProject flutterProject = FlutterProject.current();
+    setUp(() {
+      mockArtifacts = MockArtifacts();
+      mockCache = MockCache();
+      iosDeploy = IOSDeploy(
+        artifacts: mockArtifacts,
+        cache: mockCache,
+        logger: mockLogger,
+        platform: macPlatform,
+        processManager: FakeProcessManager.any(),
+      );
+    });
 
-    expect(IOSDevice('test', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64).isSupportedForProject(flutterProject), true);
-  }, overrides: <Type, Generator>{
-    FileSystem: () => MemoryFileSystem(),
-    ProcessManager: () => FakeProcessManager.any(),
-    Platform: () => macPlatform,
-  });
+    testUsingContext('is true on module project', () async {
+      globals.fs.file('pubspec.yaml')
+        ..createSync()
+        ..writeAsStringSync(r'''
+  name: example
 
-  testUsingContext('IOSDevice.isSupportedForProject is false with no host app and no module', () async {
-    globals.fs.file('pubspec.yaml').createSync();
-    globals.fs.file('.packages').createSync();
-    final FlutterProject flutterProject = FlutterProject.current();
+  flutter:
+    module: {}
+  ''');
+      globals.fs.file('.packages').createSync();
+      final FlutterProject flutterProject = FlutterProject.current();
 
-    expect(IOSDevice('test', name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64).isSupportedForProject(flutterProject), false);
-  }, overrides: <Type, Generator>{
-    FileSystem: () => MemoryFileSystem(),
-    ProcessManager: () => FakeProcessManager.any(),
-    Platform: () => macPlatform,
+      final IOSDevice device = IOSDevice(
+        'test',
+        artifacts: mockArtifacts,
+        fileSystem: globals.fs,
+        iosDeploy: iosDeploy,
+        platform: globals.platform,
+        name: 'iPhone 1',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64
+      );
+      expect(device.isSupportedForProject(flutterProject), true);
+    }, overrides: <Type, Generator>{
+      FileSystem: () => MemoryFileSystem(),
+      ProcessManager: () => FakeProcessManager.any(),
+      Platform: () => macPlatform,
+    });
+
+    testUsingContext('is true with editable host app', () async {
+      globals.fs.file('pubspec.yaml').createSync();
+      globals.fs.file('.packages').createSync();
+      globals.fs.directory('ios').createSync();
+      final FlutterProject flutterProject = FlutterProject.current();
+      final IOSDevice device = IOSDevice(
+        'test',
+        artifacts: mockArtifacts,
+        fileSystem: globals.fs,
+        iosDeploy: iosDeploy,
+        platform: globals.platform,
+        name: 'iPhone 1',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64,
+      );
+      expect(device.isSupportedForProject(flutterProject), true);
+    }, overrides: <Type, Generator>{
+      FileSystem: () => MemoryFileSystem(),
+      ProcessManager: () => FakeProcessManager.any(),
+      Platform: () => macPlatform,
+    });
+
+    testUsingContext('is false with no host app and no module', () async {
+      globals.fs.file('pubspec.yaml').createSync();
+      globals.fs.file('.packages').createSync();
+      final FlutterProject flutterProject = FlutterProject.current();
+
+      final IOSDevice device = IOSDevice(
+        'test',
+        artifacts: mockArtifacts,
+        fileSystem: globals.fs,
+        iosDeploy: iosDeploy,
+        platform: globals.platform,
+        name: 'iPhone 1',
+        sdkVersion: '13.3',
+        cpuArchitecture: DarwinArch.arm64,
+      );
+      expect(device.isSupportedForProject(flutterProject), false);
+    }, overrides: <Type, Generator>{
+      FileSystem: () => MemoryFileSystem(),
+      ProcessManager: () => FakeProcessManager.any(),
+      Platform: () => macPlatform,
+    });
   });
 }
 
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart
new file mode 100644
index 0000000..391f405
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart
@@ -0,0 +1,119 @@
+// 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 'dart:io' show Process;
+
+import 'package:flutter_tools/src/artifacts.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/build_info.dart';
+import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/ios/ios_deploy.dart';
+import 'package:mockito/mockito.dart';
+import 'package:platform/platform.dart';
+import 'package:process/process.dart';
+
+import '../../src/common.dart';
+import '../../src/mocks.dart';
+
+class MockArtifacts extends Mock implements Artifacts {}
+class MockCache extends Mock implements Cache {}
+class MockLogger extends Mock implements Logger {}
+class MockPlatform extends Mock implements Platform {}
+class MockProcess extends Mock implements Process {}
+class MockProcessManager extends Mock implements ProcessManager {}
+
+void main () {
+  group('IOSDeploy()', () {
+    Artifacts mockArtifacts;
+    Cache mockCache;
+    IOSDeploy iosDeploy;
+    Logger mockLogger;
+    Platform mockPlatform;
+    ProcessManager mockProcessManager;
+    const String iosDeployPath = '/path/to/ios-deploy';
+    const String deviceId = '123';
+    const String bundleId = 'com.example.app';
+
+    setUp(() {
+      mockArtifacts = MockArtifacts();
+      when(mockArtifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios))
+        .thenReturn(iosDeployPath);
+      mockCache = MockCache();
+      const MapEntry<String, String> mapEntry = MapEntry<String, String>('DYLD_LIBRARY_PATH', '/path/to/libs');
+      when(mockCache.dyLdLibEntry).thenReturn(mapEntry);
+      mockLogger = MockLogger();
+      mockPlatform = MockPlatform();
+      when(mockPlatform.environment).thenReturn(<String, String>{
+        'PATH': '/usr/local/bin:/usr/bin',
+      });
+      mockProcessManager = MockProcessManager();
+      iosDeploy = IOSDeploy(
+        artifacts: mockArtifacts,
+        cache: mockCache,
+        logger: mockLogger,
+        platform: mockPlatform,
+        processManager: mockProcessManager,
+      );
+    });
+
+    testWithoutContext('iosDeployEnv returns path with /usr/bin first', () {
+      final Map<String, String> env = iosDeploy.iosDeployEnv;
+      expect(env['PATH'].startsWith('/usr/bin'), true);
+    });
+
+    testWithoutContext('uninstallApp() calls ios-deploy with correct arguments and returns 0 on success', () async {
+      final List<String> args = <String>[
+        iosDeployPath,
+        '--id',
+        deviceId,
+        '--uninstall_only',
+        '--bundle_id',
+        bundleId,
+      ];
+      when(mockProcessManager.start(
+        args,
+        workingDirectory: anyNamed('workingDirectory'),
+        environment: anyNamed('environment'),
+      )).thenAnswer((Invocation invocation) => Future<Process>.value(createMockProcess(exitCode: 0)));
+      final int exitCode = await iosDeploy.uninstallApp(
+        deviceId: deviceId,
+        bundleId: bundleId,
+      );
+
+      verify(mockProcessManager.start(
+        args,
+        workingDirectory: anyNamed('workingDirectory'),
+        environment: anyNamed('environment'),
+      ));
+      expect(exitCode, 0);
+    });
+
+    testWithoutContext('uninstallApp() returns non-zero exit code when ios-deploy does the same', () async {
+      final List<String> args = <String>[
+        iosDeployPath,
+        '--id',
+        deviceId,
+        '--uninstall_only',
+        '--bundle_id',
+        bundleId,
+      ];
+      when(mockProcessManager.start(
+        args,
+        workingDirectory: anyNamed('workingDirectory'),
+        environment: anyNamed('environment'),
+      )).thenAnswer((Invocation invocation) => Future<Process>.value(createMockProcess(exitCode: 1)));
+      final int exitCode = await iosDeploy.uninstallApp(
+        deviceId: deviceId,
+        bundleId: bundleId,
+      );
+
+      verify(mockProcessManager.start(
+        args,
+        workingDirectory: anyNamed('workingDirectory'),
+        environment: anyNamed('environment'),
+      ));
+      expect(exitCode, 1);
+    });
+  });
+}