feat(mdtest run/auto): mdtest supports iOS devices

mdtest now supports running flutter apps on iOS devices.  From this
point, mdtest can launch applications on both Android and iOS system.
Since there is no command line tools that can get iOS device properties
like ADB does for Android, mdtest stores a device product ID to device
spec mapping and query the mapping for missing iOS device properties
like screen size, etc.  Fortunately, there is limited number of iOS
devices and the mapping can be built fairly easy compared to Android.

mdtest now uses a new third party tool called mobiledevice
https://github.com/imkira/mobiledevice to get iOS device properties.
But since not all properties are supported, a device property mapping is
needed.

The only missing feature for iOS device is that mdtest cannot wakeup and
unlock an iOS device from command line due to security issues.  All
other features are supported for both Android and iOS devices.

Since api-level property is only supported in Android and it is related
to the OS version, mdtest discards that property and only keep
os-version.  Also, os-version is related to platform type (e.g. Android
or iOS), so mdtest adds another spec property "platform" in the test
spec.  "platform" property must be specified for other properties that
depend on platform type.  In the current version, only "os-version"
depends on "platform".

Change-Id: Id7a310ef8307cfe544def0dab6810f3c23e0cd89
diff --git a/mdtest/lib/src/commands/auto.dart b/mdtest/lib/src/commands/auto.dart
index 1c1c0f7..26278fa 100644
--- a/mdtest/lib/src/commands/auto.dart
+++ b/mdtest/lib/src/commands/auto.dart
@@ -8,7 +8,6 @@
 import '../mobile/device.dart';
 import '../mobile/device_spec.dart';
 import '../mobile/key_provider.dart';
-import '../mobile/android.dart';
 import '../algorithms/coverage.dart';
 import '../algorithms/matching.dart';
 import '../globals.dart';
@@ -72,15 +71,17 @@
 
     List<int> errRounds = [];
     List<int> failRounds = [];
-    int roundNum = 1;
+    int roundNum = 0;
     for (Map<DeviceSpec, Device> deviceMapping in chosenMappings) {
+      roundNum++;
       printInfo('Begining of Round #$roundNum');
       MDTestRunner runner = new MDTestRunner();
 
       if (await runner.runAllApps(deviceMapping) != 0) {
         printError('Error when running applications on #Round $roundNum');
-        await uninstallTestedApps(deviceMapping);
-        errRounds.add(roundNum++);
+        await uninstallTestingApps(deviceMapping);
+        errRounds.add(roundNum);
+        printInfo('End of Round #$roundNum\n');
         continue;
       }
 
@@ -96,9 +97,9 @@
       assert(testsFailed != null);
       if (testsFailed) {
         printInfo('Some tests in Round #$roundNum failed');
-        failRounds.add(roundNum++);
+        failRounds.add(roundNum);
       } else {
-        printInfo('All tests in Round #${roundNum++} passed');
+        printInfo('All tests in Round #$roundNum passed');
       }
 
       appDeviceCoverageMatrix.hit(deviceMapping);
@@ -109,7 +110,7 @@
         await runCoverageCollectionTasks(collectorPool);
       }
 
-      await uninstallTestedApps(deviceMapping);
+      await uninstallTestingApps(deviceMapping);
       printInfo('End of Round #$roundNum\n');
     }
 
@@ -148,9 +149,9 @@
       defaultsTo: 'device-id',
       allowed: [
         'device-id',
+        'platform',
         'model-name',
         'os-version',
-        'api-level',
         'screen-size'
       ],
       help: 'Device property used to group devices to'
diff --git a/mdtest/lib/src/commands/create.dart b/mdtest/lib/src/commands/create.dart
index 005a8c0..4727b9a 100644
--- a/mdtest/lib/src/commands/create.dart
+++ b/mdtest/lib/src/commands/create.dart
@@ -14,10 +14,10 @@
 {
   "devices": {
     "{nickname}": {
+      "platform": "{optional}",
       "device-id": "{optional}",
       "model-name": "{optional}",
       "os-version": "{optional}",
-      "api-level": "{optional}",
       "screen-size": "{optional}",
       "app-root": "{required}",
       "app-path": "{required}"
@@ -32,18 +32,17 @@
 
 const String specGuide =
 'Everything in the curly braces can be replaced with your own value.\n'
-'"device-id", "model-name", "os-version", "api-level" and "screem-size" '
-'are optional.\n'
+'"device-id", "model-name", "os-version", and "screem-size" are optional.\n'
 '"app-root" and "app-path" are required.\n'
 'An example spec would be\n'
 '''
 {
   "devices": {
     "Alice": {
+      "platform": "android",
       "device-id": "HT4CWJT03204",
       "model-name": "Nexus 9",
       "os-version": "6.0",
-      "api-level": "23",
       "screen-size": "xlarge",
       "app-root": "/path/to/flutter-app",
       "app-path": "/path/to/main.dart"
@@ -59,10 +58,11 @@
 'the corresponding properties in the test spec.  You will use nicknames '
 'to establish connections between flutter drivers and devices in your '
 'test scripts.\n'
+'"platform" refers to whether it\'s an Android or iOS device.\n'
 '"device-id" is the unique id of your device.\n'
 '"model-name" is the device model name.\n'
-'"os-version" is the operating system version of your device.\n'
-'"api-level" is Android specific and refers to the API level of your device.\n'
+'"os-version" is the operating system version of your device.  '
+'This property depends on the platform.\n'
 '"screen-size" is the screen diagonal size measured in inches.  The candidate '
 'values are "small"(<3.5"), "normal"(>=3.5" && <5"), "large"(>=5" && <8") '
 'and "xlarge"(>=8").\n'
diff --git a/mdtest/lib/src/commands/helper.dart b/mdtest/lib/src/commands/helper.dart
index 015fac8..aa33421 100644
--- a/mdtest/lib/src/commands/helper.dart
+++ b/mdtest/lib/src/commands/helper.dart
@@ -41,7 +41,8 @@
   /// through the process output.  If no observatory port is found, then report
   /// error.
   Future<int> runApp(DeviceSpec deviceSpec, Device device) async {
-    if (await unlockDevice(device) != 0) {
+    // Currently, unlocking iOS device is not supported.
+    if (device.isAndroidDevice() && await unlockDevice(device) != 0) {
       printError('Device ${device.id} fails to wake up.');
       return 1;
     }
diff --git a/mdtest/lib/src/commands/run.dart b/mdtest/lib/src/commands/run.dart
index 2ddb855..bf5fea5 100644
--- a/mdtest/lib/src/commands/run.dart
+++ b/mdtest/lib/src/commands/run.dart
@@ -7,7 +7,6 @@
 import 'helper.dart';
 import '../mobile/device.dart';
 import '../mobile/device_spec.dart';
-import '../mobile/android.dart';
 import '../algorithms/matching.dart';
 import '../globals.dart';
 import '../runner/mdtest_command.dart';
@@ -30,7 +29,6 @@
     printInfo('Running "mdtest run command" ...');
 
     this._specs = await loadSpecs(argResults);
-    printTrace(_specs.toString());
     if (sanityCheckSpecs(_specs, argResults['spec']) != 0) {
       printError('Test spec does not meet requirements.');
       return 1;
@@ -57,7 +55,7 @@
 
     if (await runner.runAllApps(deviceMapping) != 0) {
       printError('Error when running applications');
-      await uninstallTestedApps(deviceMapping);
+      await uninstallTestingApps(deviceMapping);
       return 1;
     }
 
@@ -85,12 +83,12 @@
       await runCoverageCollectionTasks(collectorPool);
       printInfo('Computing code coverage for each application ...');
       if (await computeAppsCoverage(collectorPool, name) != 0) {
-        await uninstallTestedApps(deviceMapping);
+        await uninstallTestingApps(deviceMapping);
         return 1;
       }
     }
 
-    await uninstallTestedApps(deviceMapping);
+    await uninstallTestingApps(deviceMapping);
 
     return testsFailed ? 1 : 0;
   }
diff --git a/mdtest/lib/src/globals.dart b/mdtest/lib/src/globals.dart
index 11961ef..2963669 100644
--- a/mdtest/lib/src/globals.dart
+++ b/mdtest/lib/src/globals.dart
@@ -3,6 +3,9 @@
 // license that can be found in the LICENSE file.
 
 import 'base/logger.dart';
+import 'util.dart';
+
+OperatingSystemUtil os = new OperatingSystemUtil();
 
 Logger defaultLogger = new StdoutLogger();
 Logger get logger => defaultLogger;
diff --git a/mdtest/lib/src/mobile/android.dart b/mdtest/lib/src/mobile/android.dart
index 82040dd..f1e39da 100644
--- a/mdtest/lib/src/mobile/android.dart
+++ b/mdtest/lib/src/mobile/android.dart
@@ -12,6 +12,23 @@
 import '../globals.dart';
 import '../util.dart';
 
+Future<List<String>> getAndroidDeviceIDs() async {
+  List<String> androidIDs = <String>[];
+  Process process = await Process.start('adb', ['devices']);
+  RegExp androidIDPattern = new RegExp(r'^(\S+)\s+device$');
+  Stream lineStream = process.stdout
+                             .transform(new Utf8Decoder())
+                             .transform(new LineSplitter());
+  await for (var line in lineStream) {
+    Match androidIDMatcher = androidIDPattern.firstMatch(line.toString());
+    if (androidIDMatcher != null) {
+      String androidID = androidIDMatcher.group(1);
+      androidIDs.add(androidID);
+    }
+  }
+  return androidIDs;
+}
+
 const String lockProp = 'mHoldingWakeLockSuspendBlocker';
 
 /// Check if the device is locked
@@ -23,8 +40,8 @@
   bool isLocked;
   RegExp lockStatusPattern = new RegExp(lockProp + r'=(.*)');
   Stream lineStream = process.stdout
-                        .transform(new Utf8Decoder())
-                        .transform(new LineSplitter());
+                             .transform(new Utf8Decoder())
+                             .transform(new LineSplitter());
   await for (var line in lineStream) {
     Match lockMatcher = lockStatusPattern.firstMatch(line.toString());
     if (lockMatcher != null) {
@@ -61,51 +78,41 @@
   return await wakeUpAndUnlockProcess.exitCode;
 }
 
-/// Uninstall tested apps
-Future<int> uninstallTestedApps(Map<DeviceSpec, Device> deviceMapping) async {
-  int result = 0;
+// Uninstall an Android app
+Future<int> uninstallAndroidTestedApp(DeviceSpec spec, Device device) async {
+  String androidManifestPath
+    = normalizePath(spec.appRootPath, 'android/AndroidManifest.xml');
+  String androidManifest = await new File(androidManifestPath).readAsString();
 
-  for (DeviceSpec spec in deviceMapping.keys) {
-    Device device = deviceMapping[spec];
-
-    String androidManifestPath
-      = normalizePath(spec.appRootPath, 'android/AndroidManifest.xml');
-    String androidManifest = await new File(androidManifestPath).readAsString();
-
-    RegExp packagePattern
-      = new RegExp(r'<manifest[\s\S]*?package="(\S+)"\s+[\s\S]*?>', multiLine: true);
-    Match packageMatcher = packagePattern.firstMatch(androidManifest);
-    if (packageMatcher == null) {
-      printError('Package name not found in $androidManifestPath');
-      return 1;
-    }
-    String packageName = packageMatcher.group(1);
-
-    Process uninstallProcess = await Process.start(
-      'adb',
-      ['-s', '${device.id}', 'uninstall', '$packageName']
-    );
-
-    Stream lineStream = uninstallProcess.stdout
-                         .transform(new Utf8Decoder())
-                         .transform(new LineSplitter());
-    await for (var line in lineStream) {
-      printTrace('Uninstall $packageName on device ${device.id}: ${line.toString().trim()}');
-    }
-
-    uninstallProcess.stderr.drain();
-    result += await uninstallProcess.exitCode;
-  }
-
-  if (result != 0) {
-    printError('Cannot uninstall testing apps from devices');
+  RegExp packagePattern
+    = new RegExp(r'<manifest[\s\S]*?package="(\S+)"\s+[\s\S]*?>', multiLine: true);
+  Match packageMatcher = packagePattern.firstMatch(androidManifest);
+  if (packageMatcher == null) {
+    printError('Package name not found in $androidManifestPath');
     return 1;
   }
-  return 0;
+  String packageName = packageMatcher.group(1);
+
+  Process uninstallProcess = await Process.start(
+    'adb',
+    ['-s', '${device.id}', 'uninstall', '$packageName']
+  );
+
+  Stream lineStream = uninstallProcess.stdout
+                       .transform(new Utf8Decoder())
+                       .transform(new LineSplitter());
+  await for (var line in lineStream) {
+    printTrace(
+      'Uninstall $packageName on device ${device.id}: ${line.toString().trim()}'
+    );
+  }
+
+  uninstallProcess.stderr.drain();
+  return uninstallProcess.exitCode;
 }
 
 /// Get device property
-Future<String> getProperty(String deviceID, String propName) async {
+Future<String> getAndroidProperty(String deviceID, String propName) async {
   ProcessResult results = await Process.run(
     'adb',
     ['-s', deviceID, 'shell', 'getprop', propName]
@@ -114,7 +121,7 @@
 }
 
 /// Get device pixels and dpi to compute screen diagonal size in inches
-Future<String> getScreenSize(String deviceID) async {
+Future<String> getAndroidScreenSize(String deviceID) async {
   Process sizeProcess = await Process.start(
     'adb',
     ['-s', '$deviceID', 'shell', 'wm', 'size']
@@ -169,8 +176,20 @@
   double yInch = ySize / density;
   double diagonalSize = sqrt(xInch * xInch + yInch * yInch);
 
-  if (diagonalSize < 3.5) return 'small';
-  if (diagonalSize < 5) return 'normal';
-  if (diagonalSize < 8) return 'large';
-  return 'xlarge';
+  return categorizeScreenSize(diagonalSize);
+}
+
+Future<Device> collectAndroidDeviceProps(String deviceID, {String groupKey}) async {
+  return new Device(
+    properties: <String, String> {
+      'platform': 'android',
+      'device-id': deviceID,
+      'model-name': await getAndroidProperty(deviceID, 'ro.product.model'),
+      'os-version': expandOSVersion(
+        await getAndroidProperty(deviceID, 'ro.build.version.release')
+      ),
+      'screen-size': await getAndroidScreenSize(deviceID)
+    },
+    groupKey: groupKey
+  );
 }
diff --git a/mdtest/lib/src/mobile/device.dart b/mdtest/lib/src/mobile/device.dart
index c69fd74..fef0bad 100644
--- a/mdtest/lib/src/mobile/device.dart
+++ b/mdtest/lib/src/mobile/device.dart
@@ -7,6 +7,9 @@
 
 import 'key_provider.dart';
 import 'android.dart';
+import 'ios.dart';
+import 'device_spec.dart';
+import '../globals.dart';
 
 class Device implements GroupKeyProvider {
   Device({
@@ -19,29 +22,51 @@
   Map<String, String> properties;
   String _groupKey;
 
+  String get platform => properties['platform'];
   String get id => properties['device-id'];
   String get modelName => properties['model-name'];
   String get screenSize => properties['screen-size'];
   String get osVersion => properties['os-version'];
-  String get apiLevel => properties['api-level'];
+
+  bool isAndroidDevice() => platform == 'android';
+  bool isIOSDevice() => platform == 'ios';
 
   /// default to 'device-id'
   @override
   String groupKey() {
+    if (_groupKey == 'os-version') {
+      RegExp majorVersionPattern = new RegExp(r'^(\d+)\.\d+\.\d+$');
+      Match majorVersionMatch = majorVersionPattern.firstMatch(osVersion);
+      if (majorVersionMatch == null) {
+        printError('OS version $osVersion does not match semantic version.');
+        return null;
+      }
+      String majorVersion = majorVersionMatch.group(1);
+      return '$platform $majorVersion.x.x';
+    }
     return properties[_groupKey ?? 'device-id'];
   }
 
   @override
   String toString()
-    => '<device-id: $id, model-name: $modelName, screen-size: $screenSize, '
-       'os-version: $osVersion, api-level: $apiLevel>';
+    => '<platform: $platform, device-id: $id, model-name: $modelName, '
+       'screen-size: $screenSize, os-version: $osVersion>';
 }
 
 Future<List<Device>> getDevices({String groupKey}) async {
   List<Device> devices = <Device>[];
+  List<String> androidIDs = await getAndroidDeviceIDs();
+  List<String> iosIDs = await getIOSDeviceIDs();
   await _getDeviceIDs().then((List<String> ids) async {
     for(String id in ids) {
-      devices.add(await _collectDeviceProps(id, groupKey: groupKey));
+      if (androidIDs.contains(id)) {
+        devices.add(await collectAndroidDeviceProps(id, groupKey: groupKey));
+      } else if (iosIDs.contains(id)) {
+        devices.add(await collectIOSDeviceProps(id, groupKey: groupKey));
+      } else {
+        // iOS simulator
+        printError('iOS simulator $id is not supported.');
+      }
     }
   });
   return devices;
@@ -81,15 +106,54 @@
   return deviceIDs;
 }
 
-Future<Device> _collectDeviceProps(String deviceID, {String groupKey}) async {
-  return new Device(
-    properties: <String, String> {
-      'device-id': deviceID,
-      'model-name': await getProperty(deviceID, 'ro.product.model'),
-      'os-version': await getProperty(deviceID, 'ro.build.version.release'),
-      'api-level': await getProperty(deviceID, 'ro.build.version.sdk'),
-      'screen-size': await getScreenSize(deviceID)
-    },
-    groupKey: groupKey
-  );
+String expandOSVersion(String osVersion) {
+  RegExp singleNumber = new RegExp(r'^\d+$');
+  if (singleNumber.hasMatch(osVersion)) {
+    osVersion = '$osVersion.0.0';
+  }
+  RegExp doubleNumber = new RegExp(r'^\d+\.\d+$');
+  if (doubleNumber.hasMatch(osVersion)) {
+    osVersion = '$osVersion.0';
+  }
+  RegExp tripleNumber = new RegExp(r'^\d+\.\d+\.\d+$');
+  if (!tripleNumber.hasMatch(osVersion)) {
+    throw new FormatException(
+      'OS version $osVersion does not match semantic version.'
+    );
+  }
+  return osVersion;
+}
+
+String categorizeScreenSize(num diagonalSize) {
+  if (diagonalSize < 3.6) return 'small';
+  if (diagonalSize < 5) return 'normal';
+  if (diagonalSize < 8) return 'large';
+  return 'xlarge';
+}
+
+/// Uninstall tested apps
+Future<int> uninstallTestingApps(
+  Map<DeviceSpec, Device> deviceMapping
+) async {
+  int result = 0;
+
+  for (DeviceSpec spec in deviceMapping.keys) {
+    Device device = deviceMapping[spec];
+    if (device.isAndroidDevice()) {
+      result += await uninstallAndroidTestedApp(spec, device);
+    } else if (device.isIOSDevice()) {
+      result += await uninstallIOSTestedApp(spec, device);
+    } else {
+      printError(
+        'Cannot uninstall testing app from device ${device.id}.  '
+        'Platform ${device.platform} is not supported.'
+      );
+    }
+  }
+
+  if (result != 0) {
+    printError('Cannot uninstall testing apps from devices');
+    return 1;
+  }
+  return 0;
 }
diff --git a/mdtest/lib/src/mobile/device_spec.dart b/mdtest/lib/src/mobile/device_spec.dart
index 44da67a..a70c6ba 100644
--- a/mdtest/lib/src/mobile/device_spec.dart
+++ b/mdtest/lib/src/mobile/device_spec.dart
@@ -7,6 +7,7 @@
 import 'dart:io';
 
 import 'package:args/args.dart';
+import 'package:pub_semver/pub_semver.dart';
 
 import 'device.dart';
 import 'key_provider.dart';
@@ -20,11 +21,11 @@
 
   Map<String, String> specProperties;
 
+  String get platform => specProperties['platform'];
   String get nickName => specProperties['nickname'];
   String get deviceID => specProperties['device-id'];
   String get deviceModelName => specProperties['model-name'];
   String get deviceOSVersion => specProperties['os-version'];
-  String get deviceAPILevel => specProperties['api-level'];
   String get deviceScreenSize => specProperties['screen-size'];
   String get appRootPath => specProperties['app-root'];
   String get appPath => specProperties['app-path'];
@@ -37,15 +38,16 @@
   /// Checked property names includes: device-id, model-name, screen-size
   bool matches(Device device) {
     List<String> checkedProperties = [
+      'platform',
       'device-id',
       'model-name',
-      'os-version',
-      'api-level',
       'screen-size'
     ];
     return checkedProperties.every(
       (String propertyName) => isNullOrEqual(propertyName, device)
-    );
+    )
+    &&
+    osVersionIsNullOrMatches(device);
   }
 
   bool isNullOrEqual(String propertyName, Device device) {
@@ -54,6 +56,18 @@
            specProperties[propertyName] == device.properties[propertyName];
   }
 
+  bool osVersionIsNullOrMatches(Device device) {
+    String osVersion = specProperties['os-version'];
+    if (osVersion == null) {
+      return true;
+    }
+    VersionConstraint versionConstraint
+      = new VersionConstraint.parse(osVersion);
+    return versionConstraint.allows(
+      new Version.parse(device.properties['os-version'])
+    );
+  }
+
   @override
   String groupKey() {
     return appPath;
@@ -61,12 +75,12 @@
 
   @override
   String toString() => '<nickname: $nickName, '
+                       'platform: $platform, '
                        'id: $deviceID, '
                        'model name: $deviceModelName, '
                        'os version: $deviceOSVersion, '
-                       'api level: $deviceAPILevel, '
                        'screen size: $deviceScreenSize, '
-                       'port: $observatoryUrl, '
+                       'observatory url: $observatoryUrl, '
                        'app path: $appPath>';
 }
 
@@ -103,8 +117,11 @@
 /// Check if test spec meets the requirements.  If user does not specify any
 /// valid test paths neither from the test spec nor from the command line,
 /// report error.  If 'devices' property is not specified, report error.  If
-/// no device spec is specified, report error.  If screen size property is not
-/// one of 'small', 'normal', 'large' and 'xlarge', report error.  If app-root
+/// no device spec is specified, report error.  If platform property is not
+/// one of 'ios' or 'android', report error.  If os-version is specified, but
+/// platform is not specified, report error.  If os-version does not match
+/// semantic version, report error.  If screen size property is not one of
+/// 'small', 'normal', 'large' and 'xlarge', report error.  If app-root
 /// is not specified or is not a directory, report error.  If appPath is not
 /// specified or is not a file, report error.
 ///
@@ -122,20 +139,46 @@
   }
   dynamic deviceSpecs = spec['devices'];
   if (deviceSpecs == null) {
-    printError('"devices" property is not specified in $specsPath');
+    printError('"devices" property is not specified in $specsPath.');
     return 1;
   }
   if (deviceSpecs.isEmpty) {
-    printError('No device spec is found in $specsPath');
+    printError('No device spec is found in $specsPath.');
     return 1;
   }
   for (String nickname in deviceSpecs.keys) {
     dynamic individualDeviceSpec = deviceSpecs[nickname];
+    List<String> platforms = <String>['ios', 'android'];
+    String platform = individualDeviceSpec['platform'];
+    if (platform != null && !platforms.contains(platform)) {
+      printError('Platform must be one of $platforms.');
+      return 1;
+    }
+    String osVersion = individualDeviceSpec['os-version'];
+    if (osVersion != null) {
+      if (platform == null) {
+        printError(
+          'You must also specify platform type if you specify os-version.'
+        );
+        return 1;
+      }
+      try {
+        new VersionConstraint.parse(osVersion);
+      } on FormatException {
+        printError(
+          'The os-version you specified does not meet the requirement of '
+          'semantic version.'
+        );
+        return 1;
+      } catch (e) {
+        printError('Unknown Exception when parsing os-vesion, details:\n $e.');
+        return 1;
+      }
+    }
     List<String> screenSizes = <String>['small', 'normal', 'large', 'xlarge'];
-    if (individualDeviceSpec['screen-size'] != null
-        &&
-        !screenSizes.contains(individualDeviceSpec['screen-size'])) {
-      printError('Screen size must be one of $screenSizes');
+    String screenSize = individualDeviceSpec['screen-size'];
+    if (screenSize != null && !screenSizes.contains(screenSize)) {
+      printError('Screen size must be one of $screenSizes.');
       return 1;
     }
     String appRootPath = individualDeviceSpec['app-root'];
diff --git a/mdtest/lib/src/mobile/ios.dart b/mdtest/lib/src/mobile/ios.dart
new file mode 100644
index 0000000..9f35cfd
--- /dev/null
+++ b/mdtest/lib/src/mobile/ios.dart
@@ -0,0 +1,182 @@
+// Copyright 2016 The Vanadium 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 'dart:convert';
+import 'dart:io';
+
+import '../mobile/device.dart';
+import '../mobile/device_spec.dart';
+import '../globals.dart';
+import '../util.dart';
+
+Future<List<String>> getIOSDeviceIDs() async {
+  List<String> iosIDs = <String>[];
+  if (!os.isMacOS) {
+    return iosIDs;
+  }
+  Process process = await Process.start('mobiledevice', ['list_devices']);
+  RegExp iosIDPattern = new RegExp(r'^(.*)$');
+  Stream lineStream = process.stdout
+                             .transform(new Utf8Decoder())
+                             .transform(new LineSplitter());
+
+  await for (var line in lineStream) {
+    Match iosIDMatcher = iosIDPattern.firstMatch(line.toString());
+    if (iosIDMatcher != null) {
+      String iosID = iosIDMatcher.group(1);
+      iosIDs.add(iosID);
+    }
+  }
+  return iosIDs;
+}
+
+/// Uninstall an ios app
+Future<int> uninstallIOSTestedApp(DeviceSpec spec, Device device) async {
+  String iosProjectProfilePath
+    = normalizePath(spec.appRootPath, 'ios/Runner.xcodeproj/project.pbxproj');
+  String iosProjectProfile = await new File(iosProjectProfilePath).readAsString();
+
+  RegExp packagePattern
+    = new RegExp(r'PRODUCT_BUNDLE_IDENTIFIER\s*=\s*(\S+?);', multiLine: true);
+  Match packageMatcher = packagePattern.firstMatch(iosProjectProfile);
+  if (packageMatcher == null) {
+    printError('Package name not found in $iosProjectProfilePath');
+    return 1;
+  }
+  String packageName = packageMatcher.group(1);
+
+  Process uninstallProcess = await Process.start(
+    'mobiledevice',
+    ['uninstall_app', '-u', '${device.id}', '$packageName']
+  );
+
+  Stream lineStream = uninstallProcess.stdout
+                       .transform(new Utf8Decoder())
+                       .transform(new LineSplitter());
+  await for (var line in lineStream) {
+    printTrace(
+      'Uninstall $packageName on device ${device.id}: ${line.toString().trim()}'
+    );
+  }
+
+  uninstallProcess.stderr.drain();
+  return uninstallProcess.exitCode;
+}
+
+/// Get device property
+Future<String> getIOSProperty(String deviceID, String propName) async {
+  ProcessResult results = await Process.run(
+    'mobiledevice',
+    ['get_device_prop', '-u', deviceID, propName]
+  );
+  return results.stdout.toString().trim();
+}
+
+Future<Device> collectIOSDeviceProps(String deviceID, {String groupKey}) async {
+  String modelName = iosDeviceInfo['productId-to-modelName'][
+    await getIOSProperty(deviceID, 'ProductType')
+  ];
+  num diagonalSize = iosDeviceInfo['modelName-to-screenSize'][modelName];
+  return new Device(
+    properties: <String, String> {
+      'platform': 'ios',
+      'device-id': deviceID,
+      'model-name': modelName,
+      'os-version': expandOSVersion(
+        await getIOSProperty(deviceID, 'ProductVersion')
+      ),
+      'screen-size': categorizeScreenSize(diagonalSize)
+    },
+    groupKey: groupKey
+  );
+}
+
+// The information is based on https://www.theiphonewiki.com/wiki/Models
+// For now, we only support iPhone and iPad (mini)
+final dynamic iosDeviceInfo =
+{
+  'productId-to-modelName': {
+    // iPhone
+    'iPhone1,1': 'iPhone',
+    'iPhone1,2': 'iPhone 3G',
+    'iPhone2,1': 'iPhone 3GS',
+    'iPhone3,1': 'iPhone 4',
+    'iPhone3,2': 'iPhone 4',
+    'iPhone3,3': 'iPhone 4',
+    'iPhone4,1': 'iPhone 4S',
+    'iPhone5,1': 'iPhone 5',
+    'iPhone5,2': 'iPhone 5',
+    'iPhone5,3': 'iPhone 5C',
+    'iPhone5,4': 'iPhone 5C',
+    'iPhone6,1': 'iPhone 5S',
+    'iPhone6,2': 'iPhone 5S',
+    'iPhone7,2': 'iPhone 6',
+    'iPhone7,1': 'iPhone 6 Plus',
+    'iPhone8,1': 'iPhone 6S',
+    'iPhone8,2': 'iPhone 6S Plus',
+    'iPhone8,4': 'iPhone SE',
+    // iPad and iPad mini
+    'iPad1,1': 'iPad',
+    'iPad2,1': 'iPad 2',
+    'iPad2,2': 'iPad 2',
+    'iPad2,3': 'iPad 2',
+    'iPad2,4': 'iPad 2',
+    'iPad3,1': 'iPad 3',
+    'iPad3,2': 'iPad 3',
+    'iPad3,3': 'iPad 3',
+    'iPad3,4': 'iPad 4',
+    'iPad3,5': 'iPad 4',
+    'iPad3,6': 'iPad 4',
+    'iPad4,1': 'iPad Air',
+    'iPad4,2': 'iPad Air',
+    'iPad4,3': 'iPad Air',
+    'iPad5,3': 'iPad Air 2',
+    'iPad5,4': 'iPad Air 2',
+    'iPad6,3': 'iPad Pro (9.7 inch)',
+    'iPad6,4': 'iPad Pro (9.7 inch)',
+    'iPad6,7': 'iPad Pro (12.9 inch)',
+    'iPad6,8': 'iPad Pro (12.9 inch)',
+    'iPad2,5': 'iPad mini',
+    'iPad2,6': 'iPad mini',
+    'iPad2,7': 'iPad mini',
+    'iPad4,4': 'iPad mini 2',
+    'iPad4,5': 'iPad mini 2',
+    'iPad4,6': 'iPad mini 2',
+    'iPad4,7': 'iPad mini 3',
+    'iPad4,8': 'iPad mini 3',
+    'iPad4,9': 'iPad mini 3',
+    'iPad5,1': 'iPad mini 4',
+    'iPad5,2': 'iPad mini 4'
+  },
+  'modelName-to-screenSize': {
+    // iPhone
+    'iPhone': 3.5,
+    'iPhone 3G': 3.5,
+    'iPhone 3GS': 3.5,
+    'iPhone 4': 3.5,
+    'iPhone 4S': 3.5,
+    'iPhone 5': 4,
+    'iPhone 5S': 4,
+    'iPhone 5C': 4,
+    'iPhone 6': 4.7,
+    'iPhone 6 Plus': 5.5,
+    'iPhone 6S': 4.7,
+    'iPhone 6S Plus': 5.5,
+    'iPhone SE': 4,
+    // iPad
+    'iPad': 9.7,
+    'iPad 2': 9.7,
+    'iPad 3': 9.7,
+    'iPad 4': 9.7,
+    'iPad Air': 9.7,
+    'iPad Air 2': 9.7,
+    'iPad Pro (9.7 inch)': 9.7,
+    'iPad Pro (12.9 inch)': 12.9,
+    'iPad mini': 7.9,
+    'iPad mini 2': 7.9,
+    'iPad mini 3': 7.9,
+    'iPad mini 4': 7.9
+  }
+};
diff --git a/mdtest/lib/src/test/coverage_collector.dart b/mdtest/lib/src/test/coverage_collector.dart
index 2b6a064..998c625 100644
--- a/mdtest/lib/src/test/coverage_collector.dart
+++ b/mdtest/lib/src/test/coverage_collector.dart
@@ -35,14 +35,22 @@
   }) async {
     Map<String, dynamic> data = await collect(host, port, false, false);
     Map<String, dynamic> hitmap = createHitmap(data['coverage']);
-    if (_globalHitmap == null)
+    if (_globalHitmap == null) {
       _globalHitmap = hitmap;
-    else
+    } else {
       mergeHitmaps(hitmap, _globalHitmap);
+    }
   }
 
   Future<Null> finishPendingJobs() async {
-    await Future.wait(_jobs.toList(), eagerError: true);
+    await Future.wait(
+      _jobs.toList(),
+      eagerError: true
+    ).catchError(
+      (e) {
+        print('Collecting coverage error: ${e.error}');
+      }
+    );
   }
 
   Future<String> finalizeCoverage(String appRootPath) async {
diff --git a/mdtest/lib/src/util.dart b/mdtest/lib/src/util.dart
index 3b38263..af88eb0 100644
--- a/mdtest/lib/src/util.dart
+++ b/mdtest/lib/src/util.dart
@@ -10,6 +10,34 @@
 
 import 'globals.dart';
 
+class OperatingSystemUtil {
+  String _os;
+  static OperatingSystemUtil instance;
+
+  factory OperatingSystemUtil() {
+    if (instance == null) {
+      instance = new OperatingSystemUtil._internal(Platform.operatingSystem);
+    }
+    return instance;
+  }
+
+  bool get isMacOS => _os == 'macos';
+  bool get isWindows => _os == 'windows';
+  bool get isLinux => _os == 'linux';
+
+  /// Return the path (with symlinks resolved) to the given executable, or `null`
+  /// if `which` was not able to locate the binary.
+  File which(String execName) {
+    ProcessResult result = Process.runSync('which', <String>[execName]);
+    if (result.exitCode != 0)
+      return null;
+    String path = result.stdout.trim().split('\n').first.trim();
+    return new File(new File(path).resolveSymbolicLinksSync());
+  }
+
+  OperatingSystemUtil._internal(this._os);
+}
+
 // '=' * 20
 const String doubleLineSeparator = '====================';
 // '-' * 20
diff --git a/mdtest/pubspec.yaml b/mdtest/pubspec.yaml
index aad05ef..193478b 100644
--- a/mdtest/pubspec.yaml
+++ b/mdtest/pubspec.yaml
@@ -7,6 +7,7 @@
   coverage: ^0.7.9
   glob: ">=1.1.3"
   intl: ">=0.12.4+2"
+  pub_semver: "1.3.0"
   dlog:
     path: ../../../../third_party/dart/dlog
   flutter_driver: