feat(mdtest run/auto): unlock devices if necessary and uninstall
testing apps after test execution.  support 'screen-size' property
in test spec.

Wake up and unlock the device if it is locked.  This is good for
integration testing, where the devices are locked after not being
used for a long time.  Uninstall tested apps after test execution.
This feature is supported to avoid too many apps running in
background if a device is used to test multiple different apps.

Also add screen-size support in test spec.

Change-Id: Id0f9a209b2da15d944fc383442fc804789974118
diff --git a/mdtest/lib/src/commands/auto.dart b/mdtest/lib/src/commands/auto.dart
index 16adf00..a176229 100644
--- a/mdtest/lib/src/commands/auto.dart
+++ b/mdtest/lib/src/commands/auto.dart
@@ -4,10 +4,11 @@
 
 import 'dart:async';
 
-import 'runner.dart';
+import 'helper.dart';
 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';
@@ -67,7 +68,8 @@
 
       if (await runner.runAllApps(deviceMapping) != 0) {
         printError('Error when running applications');
-        errRounds.add(roundNum);
+        await uninstallTestedApps(deviceMapping);
+        errRounds.add(roundNum++);
         continue;
       }
 
@@ -75,9 +77,12 @@
 
       if (await runner.runTest(_specs['test-path']) != 0) {
         printError('Test execution exit with error.');
-        errRounds.add(roundNum);
+        await uninstallTestedApps(deviceMapping);
+        errRounds.add(roundNum++);
         continue;
       }
+
+      await uninstallTestedApps(deviceMapping);
     }
 
     if (errRounds.isNotEmpty) {
diff --git a/mdtest/lib/src/commands/helper.dart b/mdtest/lib/src/commands/helper.dart
new file mode 100644
index 0000000..ee5bd5d
--- /dev/null
+++ b/mdtest/lib/src/commands/helper.dart
@@ -0,0 +1,99 @@
+// 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 '../mobile/android.dart';
+import '../globals.dart';
+
+class MDTestRunner {
+  List<Process> appProcesses;
+
+  MDTestRunner() {
+    appProcesses = <Process>[];
+  }
+
+  /// Invoke runApp function for each device spec to device mapping in parallel
+  Future<int> runAllApps(Map<DeviceSpec, Device> deviceMapping) async {
+    List<Future<int>> runAppList = <Future<int>>[];
+    for (DeviceSpec deviceSpec in deviceMapping.keys) {
+      Device device = deviceMapping[deviceSpec];
+      runAppList.add(runApp(deviceSpec, device));
+    }
+    int res = 0;
+    List<int> results = await Future.wait(runAppList);
+    for (int result in results)
+        res += result;
+    return res == 0 ? 0 : 1;
+  }
+
+  /// Create a process that runs 'flutter run ...' command which installs and
+  /// starts the app on the device.  The function finds a observatory port
+  /// 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) {
+      printError('Device ${device.id} fails to wake up.');
+      return 1;
+    }
+
+    Process process = await Process.start(
+      'flutter',
+      ['run', '-d', '${device.id}', '--target=${deviceSpec.appPath}'],
+      workingDirectory: deviceSpec.appRootPath
+    );
+    appProcesses.add(process);
+    Stream lineStream = process.stdout
+                               .transform(new Utf8Decoder())
+                               .transform(new LineSplitter());
+    RegExp portPattern = new RegExp(r'Observatory listening on (http.*)');
+    await for (var line in lineStream) {
+      print(line.toString().trim());
+      Match portMatch = portPattern.firstMatch(line.toString());
+      if (portMatch != null) {
+        deviceSpec.observatoryUrl = portMatch.group(1);
+        break;
+      }
+    }
+
+    process.stderr.drain();
+
+    if (deviceSpec.observatoryUrl == null) {
+      printError('No observatory url is found.');
+      return 1;
+    }
+
+    return 0;
+  }
+
+  /// Create a process and invoke 'dart testPath' to run the test script.  After
+  /// test result is returned (either pass or fail), kill all app processes and
+  /// return the current process exit code
+  Future<int> runTest(String testPath) async {
+    Process process = await Process.start('dart', ['$testPath']);
+    RegExp testStopPattern = new RegExp(r'All tests passed|Some tests failed');
+    Stream stdoutStream = process.stdout
+                                 .transform(new Utf8Decoder())
+                                 .transform(new LineSplitter());
+    await for (var line in stdoutStream) {
+      print(line.toString().trim());
+      if (testStopPattern.hasMatch(line.toString()))
+        break;
+    }
+    killAppProcesses();
+    process.stderr.drain();
+    return await process.exitCode;
+  }
+
+  /// Kill all app processes
+  Future<Null> killAppProcesses() async {
+    for (Process process in appProcesses) {
+      process.kill();
+    }
+  }
+}
diff --git a/mdtest/lib/src/commands/run.dart b/mdtest/lib/src/commands/run.dart
index 6f698e4..edbdf7a 100644
--- a/mdtest/lib/src/commands/run.dart
+++ b/mdtest/lib/src/commands/run.dart
@@ -4,9 +4,10 @@
 
 import 'dart:async';
 
-import 'runner.dart';
+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';
@@ -51,6 +52,7 @@
 
     if (await runner.runAllApps(deviceMapping) != 0) {
       printError('Error when running applications');
+      await uninstallTestedApps(deviceMapping);
       return 1;
     }
 
@@ -58,9 +60,12 @@
 
     if (await runner.runTest(_specs['test-path']) != 0) {
       printError('Test execution exit with error.');
+      await uninstallTestedApps(deviceMapping);
       return 1;
     }
 
+    await uninstallTestedApps(deviceMapping);
+
     return 0;
   }
 
diff --git a/mdtest/lib/src/mobile/android.dart b/mdtest/lib/src/mobile/android.dart
new file mode 100644
index 0000000..a0072d4
--- /dev/null
+++ b/mdtest/lib/src/mobile/android.dart
@@ -0,0 +1,176 @@
+// 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:math';
+import 'dart:io';
+
+import '../mobile/device.dart';
+import '../mobile/device_spec.dart';
+import '../globals.dart';
+import '../util.dart';
+
+const String lockProp = 'mHoldingWakeLockSuspendBlocker';
+
+/// Check if the device is locked
+Future<bool> _deviceIsLocked(Device device) async {
+  Process process = await Process.start(
+    'adb',
+    ['-s', '${device.id}', 'shell', 'dumpsys', 'power']
+  );
+  bool isLocked;
+  RegExp lockStatusPattern = new RegExp(lockProp + r'=(.*)');
+  Stream lineStream = process.stdout
+                        .transform(new Utf8Decoder())
+                        .transform(new LineSplitter());
+  await for (var line in lineStream) {
+    Match lockMatcher = lockStatusPattern.firstMatch(line.toString());
+    if (lockMatcher != null) {
+      isLocked = lockMatcher.group(1) == 'false';
+      break;
+    }
+  }
+
+  process.stderr.drain();
+  await process.exitCode;
+
+  return isLocked;
+}
+
+/// Unlock devices if the device is locked
+Future<int> unlockDevice(Device device) async {
+
+  bool isLocked = await _deviceIsLocked(device);
+
+  if (isLocked == null) {
+    printError('adb error: cannot find device $lockProp property');
+    return 1;
+  }
+
+  if (!isLocked) return 0;
+
+  Process wakeUpAndUnlockProcess = await Process.start(
+    'adb',
+    ['-s', '${device.id}', 'shell', 'input', 'keyevent', 'KEYCODE_MENU']
+  );
+  wakeUpAndUnlockProcess.stdout.drain();
+  wakeUpAndUnlockProcess.stderr.drain();
+
+  return await wakeUpAndUnlockProcess.exitCode;
+}
+
+/// Uninstall tested apps
+Future<int> uninstallTestedApps(Map<DeviceSpec, Device> deviceMapping) async {
+  int result = 0;
+
+  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) {
+      print('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');
+    return 1;
+  }
+  return 0;
+}
+
+/// Get device property
+Future<String> getProperty(String deviceID, String propName) async {
+  ProcessResult results = await Process.run(
+    'adb',
+    ['-s', deviceID, 'shell', 'getprop', propName]
+  );
+  return results.stdout.toString().trim();
+}
+
+/// Get device pixels and dpi to compute screen diagonal size in inches
+Future<String> getScreenSize(String deviceID) async {
+  Process sizeProcess = await Process.start(
+    'adb',
+    ['-s', '$deviceID', 'shell', 'wm', 'size']
+  );
+  RegExp sizePattern = new RegExp(r'Physical size:\s*(\d+)x(\d+)');
+  Stream sizeLineStream = sizeProcess.stdout
+                                     .transform(new Utf8Decoder())
+                                    .transform(new LineSplitter());
+  int xSize;
+  int ySize;
+  await for (var line in sizeLineStream) {
+    Match sizeMatcher = sizePattern.firstMatch(line.toString());
+    if (sizeMatcher != null) {
+      xSize = int.parse(sizeMatcher.group(1));
+      ySize = int.parse(sizeMatcher.group(2));
+      break;
+    }
+  }
+
+  if (xSize == null || ySize == null) {
+    printError('Screen size not found.');
+    return null;
+  }
+
+  sizeProcess.stderr.drain();
+
+  Process densityProcess = await Process.start(
+    'adb',
+    ['-s', '$deviceID', 'shell', 'wm', 'density']
+  );
+  RegExp densityPattern = new RegExp(r'Physical density:\s*(\d+)');
+  Stream densityLineStream = densityProcess.stdout
+                                           .transform(new Utf8Decoder())
+                                           .transform(new LineSplitter());
+  int density;
+  await for (var line in densityLineStream) {
+    Match densityMatcher = densityPattern.firstMatch(line.toString());
+    if (densityMatcher != null) {
+      density = int.parse(densityMatcher.group(1));
+      break;
+    }
+  }
+
+  if (density == null) {
+    printError('Density not found.');
+    return null;
+  }
+
+  densityProcess.stderr.drain();
+
+  double xInch = xSize / density;
+  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';
+}
diff --git a/mdtest/lib/src/mobile/device.dart b/mdtest/lib/src/mobile/device.dart
index ca8b4ad..574114f 100644
--- a/mdtest/lib/src/mobile/device.dart
+++ b/mdtest/lib/src/mobile/device.dart
@@ -6,15 +6,18 @@
 import 'dart:io';
 
 import 'key_provider.dart';
+import 'android.dart';
 
 class Device implements ClusterKeyProvider {
   Device({
-    this.id,
-    this.modelName
+    this.properties
   });
 
-  final String id;
-  final String modelName;
+  Map<String, String> properties;
+
+  String get id => properties['device-id'];
+  String get modelName => properties['model-name'];
+  String get screenSize => properties['screen-size'];
 
   @override
   String clusterKey() {
@@ -22,7 +25,8 @@
   }
 
   @override
-  String toString() => '<id: $id, model-name: $modelName>';
+  String toString()
+    => '<id: $id, model-name: $modelName, screen-size: $screenSize>';
 }
 
 Future<List<Device>> getDevices() async {
@@ -45,16 +49,14 @@
   bool startReading = false;
   RegExp startPattern = new RegExp(r'List of devices attached');
   RegExp deviceIDPattern = new RegExp(r'\s+(\w+)\s+.*');
-  RegExp stopPatternWithDevices = new RegExp(r'\d+ connected devices?');
-  RegExp stopPatternWithoutDevices = new RegExp(r'No devices detected');
+  RegExp stopPattern = new RegExp(r'\d+ connected devices?|No devices detected');
   await for (var line in lineStream) {
     if (!startReading && startPattern.hasMatch(line.toString())) {
       startReading = true;
       continue;
     }
 
-    if (stopPatternWithDevices.hasMatch(line.toString())
-        || stopPatternWithoutDevices.hasMatch(line.toString()))
+    if (stopPattern.hasMatch(line.toString()))
       break;
 
     if (startReading) {
@@ -73,12 +75,10 @@
 
 Future<Device> _collectDeviceProps(String deviceID) async {
   return new Device(
-    id: deviceID,
-    modelName: await _getProperty(deviceID, 'ro.product.model')
+    properties: <String, String> {
+      'device-id': deviceID,
+      'model-name': await getProperty(deviceID, 'ro.product.model'),
+      'screen-size': await getScreenSize(deviceID)
+    }
   );
 }
-
-Future<String> _getProperty(String deviceID, String propName) async {
-  ProcessResult results = await Process.run('adb', ['-s', deviceID, 'shell', 'getprop', propName]);
-  return results.stdout.toString().trim();
-}
diff --git a/mdtest/lib/src/mobile/device_spec.dart b/mdtest/lib/src/mobile/device_spec.dart
index 44543a8..042b055 100644
--- a/mdtest/lib/src/mobile/device_spec.dart
+++ b/mdtest/lib/src/mobile/device_spec.dart
@@ -6,42 +6,46 @@
 import 'dart:convert';
 import 'dart:io';
 
-import 'package:path/path.dart' as path;
-
 import 'device.dart';
 import 'key_provider.dart';
 import '../globals.dart';
+import '../util.dart';
 
 class DeviceSpec implements ClusterKeyProvider {
-  DeviceSpec(
-    {
-      this.nickName,
-      this.deviceID,
-      this.deviceModelName,
-      this.appRootPath,
-      this.appPath,
-      this.observatoryUrl
-    }
-  );
+  DeviceSpec(String nickname, { this.specProperties }) {
+    specProperties['nickname'] = nickname;
+  }
 
-  final String nickName;
-  final String deviceID;
-  final String deviceModelName;
-  final String appRootPath;
-  final String appPath;
-  String observatoryUrl;
+  Map<String, String> specProperties;
 
-  // TODO(kaiyuanw): rewrite matches function later if necessary
+  String get nickName => specProperties['nickname'];
+  String get deviceID => specProperties['device-id'];
+  String get deviceModelName => specProperties['model-name'];
+  String get deviceScreenSize => specProperties['screen-size'];
+  String get appRootPath => specProperties['app-root'];
+  String get appPath => specProperties['app-path'];
+  String get observatoryUrl => specProperties['observatory-url'];
+  void set observatoryUrl(String url) {
+    specProperties['observatory-url'] = url;
+  }
+
+  /// Match if property names are not specified or equal to the device property.
+  /// Checked property names includes: device-id, model-name, screen-size
   bool matches(Device device) {
-    if(deviceID == device.id) {
-      return deviceModelName == null ?
-               true : deviceModelName == device.modelName;
-    } else {
-      return deviceID == null ?
-               (deviceModelName == null ?
-                 true : deviceModelName == device.modelName)
-               : false;
-    }
+    List<String> checkedProperties = [
+      'device-id',
+      'model-name',
+      'screen-size'
+    ];
+    return checkedProperties.every(
+      (String propertyName) => isNullOrEqual(propertyName, device)
+    );
+  }
+
+  bool isNullOrEqual(String propertyName, Device device) {
+    return specProperties[propertyName] == null
+           ||
+           specProperties[propertyName] == device.properties[propertyName];
   }
 
   @override
@@ -50,8 +54,11 @@
   }
 
   @override
-  String toString() => '<nickname: $nickName, id: $deviceID, '
-                       'model name: $deviceModelName, port: $observatoryUrl, '
+  String toString() => '<nickname: $nickName, '
+                       'id: $deviceID, '
+                       'model name: $deviceModelName, '
+                       'screen size: $deviceScreenSize, '
+                       'port: $observatoryUrl, '
                        'app path: $appPath>';
 }
 
@@ -81,22 +88,15 @@
   }
 }
 
-String normalizePath(String rootPath, String relativePath) {
-  return path.normalize(path.join(rootPath, relativePath));
-}
-
 /// Build a list of device specs from mappings loaded from JSON .spec file
 Future<List<DeviceSpec>> constructAllDeviceSpecs(dynamic allSpecs) async {
   List<DeviceSpec> deviceSpecs = <DeviceSpec>[];
   for(String name in allSpecs.keys) {
-    Map<String, String> specs = allSpecs[name];
+    Map<String, String> spec = allSpecs[name];
     deviceSpecs.add(
       new DeviceSpec(
-        nickName: name,
-        deviceID: specs['device-id'],
-        deviceModelName: specs['model-name'],
-        appRootPath: specs['app-root'],
-        appPath: specs['app-path']
+        name,
+        specProperties: spec
       )
     );
   }
diff --git a/mdtest/lib/src/util.dart b/mdtest/lib/src/util.dart
index 8c53afe..7f85405 100644
--- a/mdtest/lib/src/util.dart
+++ b/mdtest/lib/src/util.dart
@@ -5,6 +5,8 @@
 import 'dart:io' show Platform;
 import 'dart:math';
 
+import 'package:path/path.dart' as path;
+
 int minLength(List<String> elements) {
   if (elements == null || elements.isEmpty) return -1;
   return elements.map((String e) => e.length).reduce(min);
@@ -32,3 +34,7 @@
   }
   return minL;
 }
+
+String normalizePath(String rootPath, String relativePath) {
+  return path.normalize(path.join(rootPath, relativePath));
+}