// Copyright 2015 The Chromium 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:io';

import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;

import 'application_package.dart';
import 'build_configuration.dart';
import 'process.dart';

final Logger _logging = new Logger('flutter_tools.device');

abstract class Device {
  final String id;
  static Map<String, Device> _deviceCache = {};

  static Device _unique(String id, Device constructor(String id)) {
    return _deviceCache.putIfAbsent(id, () => constructor(id));
  }

  Device._(this.id);

  /// Install an app package on the current device
  bool installApp(ApplicationPackage app);

  /// Check if the device is currently connected
  bool isConnected();

  /// Check if the current version of the given app is already installed
  bool isAppInstalled(ApplicationPackage app);

  TargetPlatform get platform;

  Future<int> logs({bool clear: false});

  /// Start an app package on the current device
  Future<bool> startApp(ApplicationPackage app);

  /// Stop an app package on the current device
  Future<bool> stopApp(ApplicationPackage app);
}

class IOSDevice extends Device {
  static final String defaultDeviceID = 'default_ios_id';

  static const String _macInstructions =
      'To work with iOS devices, please install ideviceinstaller. '
      'If you use homebrew, you can install it with '
      '"\$ brew install ideviceinstaller".';
  static const String _linuxInstructions =
      'To work with iOS devices, please install ideviceinstaller. '
      'On Ubuntu or Debian, you can install it with '
      '"\$ apt-get install ideviceinstaller".';

  String _installerPath;
  String get installerPath => _installerPath;

  String _listerPath;
  String get listerPath => _listerPath;

  String _informerPath;
  String get informerPath => _informerPath;

  String _debuggerPath;
  String get debuggerPath => _debuggerPath;

  String _loggerPath;
  String get loggerPath => _loggerPath;

  String _pusherPath;
  String get pusherPath => _pusherPath;

  String _name;
  String get name => _name;

  factory IOSDevice({String id, String name}) {
    IOSDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new IOSDevice._(id));
    device._name = name;
    return device;
  }

  IOSDevice._(String id) : super._(id) {
    _installerPath = _checkForCommand('ideviceinstaller');
    _listerPath = _checkForCommand('idevice_id');
    _informerPath = _checkForCommand('ideviceinfo');
    _debuggerPath = _checkForCommand('idevicedebug');
    _loggerPath = _checkForCommand('idevicesyslog');
    _pusherPath = _checkForCommand(
        'ios-deploy',
        'To copy files to iOS devices, please install ios-deploy. '
        'You can do this using homebrew as follows:\n'
        '\$ brew tap flutter/flutter\n'
        '\$ brew install ios-deploy',
        'Copying files to iOS devices is not currently supported on Linux.');
  }

  static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
    List<IOSDevice> devices = [];
    for (String id in _getAttachedDeviceIDs(mockIOS)) {
      String name = _getDeviceName(id, mockIOS);
      devices.add(new IOSDevice(id: id, name: name));
    }
    return devices;
  }

  static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) {
    String listerPath =
        (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id');
    String output;
    try {
      output = runSync([listerPath, '-l']);
    } catch (e) {
      return [];
    }
    return output.trim()
                 .split('\n')
                 .where((String s) => s != null && s.length > 0);
  }

  static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) {
    String informerPath = (mockIOS != null)
        ? mockIOS.informerPath
        : _checkForCommand('ideviceinfo');
    return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]);
  }

  static final Map<String, String> _commandMap = {};
  static String _checkForCommand(String command,
      [String macInstructions = _macInstructions,
      String linuxInstructions = _linuxInstructions]) {
    return _commandMap.putIfAbsent(command, () {
      try {
        command = runCheckedSync(['which', command]).trim();
      } catch (e) {
        if (Platform.isMacOS) {
          _logging.severe(macInstructions);
        } else if (Platform.isLinux) {
          _logging.severe(linuxInstructions);
        } else {
          _logging.severe('$command is not available on your platform.');
        }
      }
      return command;
    });
  }

  @override
  bool installApp(ApplicationPackage app) {
    try {
      if (id == defaultDeviceID) {
        runCheckedSync([installerPath, '-i', app.localPath]);
      } else {
        runCheckedSync([installerPath, '-u', id, '-i', app.localPath]);
      }
      return true;
    } catch (e) {
      return false;
    }
    return false;
  }

  @override
  bool isConnected() {
    Iterable<String> ids = _getAttachedDeviceIDs();
    for (String id in ids) {
      if (id == this.id || this.id == defaultDeviceID) {
        return true;
      }
    }
    return false;
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String apps = runCheckedSync([installerPath, '-l']);
      if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
        return true;
      }
    } catch (e) {
      return false;
    }
    return false;
  }

  @override
  Future<bool> startApp(ApplicationPackage app) async {
    if (!isAppInstalled(app)) {
      return false;
    }
    // idevicedebug hangs forever after launching the app, so kill it after
    // giving it plenty of time to send the launch command.
    return await runAndKill(
      [debuggerPath, 'run', app.id],
      new Duration(seconds: 3)
    ).then(
      (_) {
        return true;
      }, onError: (e) {
        _logging.info('Failure running $debuggerPath: ', e);
        return false;
      }
    );
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  Future<bool> pushFile(
      ApplicationPackage app, String localFile, String targetFile) async {
    if (Platform.isMacOS) {
      runSync([
        pusherPath,
        '-t',
        '1',
        '--bundle_id',
        app.id,
        '--upload',
        localFile,
        '--to',
        targetFile
      ]);
      return true;
    } else {
      // TODO(iansf): It may be possible to make this work on Linux. Since this
      //              functionality appears to be the only that prevents us from
      //              supporting iOS on Linux, it may be worth putting some time
      //              into investigating this.
      //              See https://bbs.archlinux.org/viewtopic.php?id=192655
      return false;
    }
    return false;
  }

  @override
  TargetPlatform get platform => TargetPlatform.iOS;

  /// Note that clear is not supported on iOS at this time.
  Future<int> logs({bool clear: false}) async {
    if (!isConnected()) {
      return 2;
    }
    return await runCommandAndStreamOutput([loggerPath],
        prefix: 'iOS dev: ', filter: new RegExp(r'.*SkyShell.*'));
  }
}

class IOSSimulator extends Device {
  static final String defaultDeviceID = 'default_ios_sim_id';

  static const String _macInstructions =
      'To work with iOS devices, please install ideviceinstaller. '
      'If you use homebrew, you can install it with '
      '"\$ brew install ideviceinstaller".';

  static String _xcrunPath = path.join('/usr', 'bin', 'xcrun');

  String _iOSSimPath;
  String get iOSSimPath => _iOSSimPath;

  String get xcrunPath => _xcrunPath;

  String _name;
  String get name => _name;

  factory IOSSimulator({String id, String name, String iOSSimulatorPath}) {
    IOSSimulator device = Device._unique(id ?? defaultDeviceID, (String id) => new IOSSimulator._(id));
    device._name = name;
    if (iOSSimulatorPath == null) {
      iOSSimulatorPath = path.join('/Applications', 'iOS Simulator.app',
          'Contents', 'MacOS', 'iOS Simulator');
    }
    device._iOSSimPath = iOSSimulatorPath;
    return device;
  }

  IOSSimulator._(String id) : super._(id);

  static String _getRunningSimulatorID([IOSSimulator mockIOS]) {
    String xcrunPath = mockIOS != null ? mockIOS.xcrunPath : _xcrunPath;
    String output = runCheckedSync([xcrunPath, 'simctl', 'list', 'devices']);

    Match match;
    Iterable<Match> matches = new RegExp(r'[^\(]+\(([^\)]+)\) \(Booted\)',
        multiLine: true).allMatches(output);
    if (matches.length > 1) {
      // More than one simulator is listed as booted, which is not allowed but
      // sometimes happens erroneously.  Kill them all because we don't know
      // which one is actually running.
      _logging.warning('Multiple running simulators were detected, '
          'which is not supposed to happen.');
      for (Match m in matches) {
        if (m.groupCount > 0) {
          _logging.warning('Killing simulator ${m.group(1)}');
          runSync([xcrunPath, 'simctl', 'shutdown', m.group(1)]);
        }
      }
    } else if (matches.length == 1) {
      match = matches.first;
    }

    if (match != null && match.groupCount > 0) {
      return match.group(1);
    } else {
      _logging.info('No running simulators found');
      return null;
    }
  }

  String _getSimulatorPath() {
    String deviceID = id == defaultDeviceID ? _getRunningSimulatorID() : id;
    String homeDirectory = path.absolute(Platform.environment['HOME']);
    if (deviceID == null) {
      return null;
    }
    return path.join(homeDirectory, 'Library', 'Developer', 'CoreSimulator',
        'Devices', deviceID);
  }

  String _getSimulatorAppHomeDirectory(ApplicationPackage app) {
    String simulatorPath = _getSimulatorPath();
    if (simulatorPath == null) {
      return null;
    }
    return path.join(simulatorPath, 'data');
  }

  static List<IOSSimulator> getAttachedDevices([IOSSimulator mockIOS]) {
    List<IOSSimulator> devices = [];
    String id = _getRunningSimulatorID(mockIOS);
    if (id != null) {
      // TODO(iansf): get the simulator's name
      // String name = _getDeviceName(id, mockIOS);
      devices.add(new IOSSimulator(id: id));
    }
    return devices;
  }

  Future<bool> boot() async {
    if (!Platform.isMacOS) {
      return false;
    }
    if (isConnected()) {
      return true;
    }
    if (id == defaultDeviceID) {
      runDetached([iOSSimPath]);
      Future<bool> checkConnection([int attempts = 20]) async {
        if (attempts == 0) {
          _logging.info('Timed out waiting for iOS Simulator $id to boot.');
          return false;
        }
        if (!isConnected()) {
          _logging.info('Waiting for iOS Simulator $id to boot...');
          return await new Future.delayed(new Duration(milliseconds: 500),
              () => checkConnection(attempts - 1));
        }
        return true;
      }
      return await checkConnection();
    } else {
      try {
        runCheckedSync([xcrunPath, 'simctl', 'boot', id]);
      } catch (e) {
        _logging.warning('Unable to boot iOS Simulator $id: ', e);
        return false;
      }
    }
    return false;
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!isConnected()) {
      return false;
    }
    try {
      if (id == defaultDeviceID) {
        runCheckedSync([xcrunPath, 'simctl', 'install', 'booted', app.localPath]);
      } else {
        runCheckedSync([xcrunPath, 'simctl', 'install', id, app.localPath]);
      }
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  bool isConnected() {
    if (!Platform.isMacOS) {
      return false;
    }
    String simulatorID = _getRunningSimulatorID();
    if (simulatorID == null) {
      return false;
    } else if (id == defaultDeviceID) {
      return true;
    } else {
      return _getRunningSimulatorID() == id;
    }
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
      return FileSystemEntity.isDirectorySync(simulatorHomeDirectory);
    } catch (e) {
      return false;
    }
  }

  @override
  Future<bool> startApp(ApplicationPackage app) async {
    if (!isAppInstalled(app)) {
      return false;
    }
    try {
      if (id == defaultDeviceID) {
        runCheckedSync(
            [xcrunPath, 'simctl', 'launch', 'booted', app.id]);
      } else {
        runCheckedSync([xcrunPath, 'simctl', 'launch', id, app.id]);
      }
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  Future<bool> pushFile(
      ApplicationPackage app, String localFile, String targetFile) async {
    if (Platform.isMacOS) {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
      runCheckedSync(
          ['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]);
      return true;
    }
    return false;
  }

  @override
  TargetPlatform get platform => TargetPlatform.iOSSimulator;

  Future<int> logs({bool clear: false}) async {
    if (!isConnected()) {
      return 2;
    }
    String homeDirectory = path.absolute(Platform.environment['HOME']);
    String simulatorDeviceID = _getRunningSimulatorID();
    String logFilePath = path.join(homeDirectory, 'Library', 'Logs',
        'CoreSimulator', simulatorDeviceID, 'system.log');
    if (clear) {
      runSync(['rm', logFilePath]);
    }
    return await runCommandAndStreamOutput(['tail', '-f', logFilePath],
        prefix: 'iOS sim: ', filter: new RegExp(r'.*SkyShell.*'));
  }
}

class AndroidDevice extends Device {
  static const String _ADB_PATH = 'adb';
  static const int _observatoryPort = 8181;

  static final String defaultDeviceID = 'default_android_device';

  String productID;
  String modelID;
  String deviceCodeName;

  String _adbPath;
  String get adbPath => _adbPath;
  bool _hasAdb = false;
  bool _hasValidAndroid = false;

  factory AndroidDevice(
      {String id: null,
      String productID: null,
      String modelID: null,
      String deviceCodeName: null}) {
    AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id));
    device.productID = productID;
    device.modelID = modelID;
    device.deviceCodeName = deviceCodeName;
    return device;
  }

  /// mockAndroid argument is only to facilitate testing with mocks, so that
  /// we don't have to rely on the test setup having adb available to it.
  static List<AndroidDevice> getAttachedDevices([AndroidDevice mockAndroid]) {
    List<AndroidDevice> devices = [];
    String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : _getAdbPath();

    try {
      runCheckedSync([adbPath, 'version']);
    } catch (e) {
      _logging.severe('Unable to find adb. Is "adb" in your path?');
      return devices;
    }

    List<String> output = runSync([adbPath, 'devices', '-l']).trim().split('\n');

    // 015d172c98400a03       device usb:340787200X product:nakasi model:Nexus_7 device:grouper
    RegExp deviceRegex1 = new RegExp(
        r'^(\S+)\s+device\s+.*product:(\S+)\s+model:(\S+)\s+device:(\S+)$');

    // 0149947A0D01500C       device usb:340787200X
    RegExp deviceRegex2 = new RegExp(r'^(\S+)\s+device\s+\S+$');

    RegExp unauthorizedRegex = new RegExp(r'^(\S+)\s+unauthorized$');

    // Skip first line, which is always 'List of devices attached'.
    for (String line in output.skip(1)) {
      // Skip lines like:
      // * daemon not running. starting it now on port 5037 *
      // * daemon started successfully *
      if (line.startsWith('* daemon '))
        continue;

      if (deviceRegex1.hasMatch(line)) {
        Match match = deviceRegex1.firstMatch(line);
        String deviceID = match[1];
        String productID = match[2];
        String modelID = match[3];
        String deviceCodeName = match[4];

        devices.add(new AndroidDevice(
            id: deviceID,
            productID: productID,
            modelID: modelID,
            deviceCodeName: deviceCodeName
        ));
      } else if (deviceRegex2.hasMatch(line)) {
        Match match = deviceRegex2.firstMatch(line);
        String deviceID = match[1];
        devices.add(new AndroidDevice(id: deviceID));
      } else if (unauthorizedRegex.hasMatch(line)) {
        Match match = unauthorizedRegex.firstMatch(line);
        String deviceID = match[1];
        _logging.warning(
          'Device $deviceID is not authorized.\n'
          'You might need to check your device for an authorization dialog.'
        );
      } else {
        _logging.warning(
          'Unexpected failure parsing device information from adb output:\n'
          '$line\n'
          'Please report a bug at https://github.com/flutter/flutter/issues/new');
      }
    }
    return devices;
  }

  AndroidDevice._(id) : super._(id) {
    _adbPath = _getAdbPath();
    _hasAdb = _checkForAdb();

    // Checking for Jelly Bean only needs to be done if we are starting an
    // app, but it has an important side effect, which is to discard any
    // progress messages if the adb server is restarted.
    _hasValidAndroid = _checkForSupportedAndroidVersion();

    if (!_hasAdb || !_hasValidAndroid) {
      _logging.warning('Unable to run on Android.');
    }
  }

  static String _getAdbPath() {
    if (Platform.environment.containsKey('ANDROID_HOME')) {
      String androidHomeDir = Platform.environment['ANDROID_HOME'];
      String adbPath1 =
          path.join(androidHomeDir, 'sdk', 'platform-tools', 'adb');
      String adbPath2 = path.join(androidHomeDir, 'platform-tools', 'adb');
      if (FileSystemEntity.isFileSync(adbPath1)) {
        return adbPath1;
      } else if (FileSystemEntity.isFileSync(adbPath2)) {
        return adbPath2;
      } else {
        _logging.info('"adb" not found at\n  "$adbPath1" or\n  "$adbPath2"\n' +
            'using default path "$_ADB_PATH"');
        return _ADB_PATH;
      }
    } else {
      return _ADB_PATH;
    }
  }

  List<String> adbCommandForDevice(List<String> args) {
    List<String> result = <String>[adbPath];
    if (id != defaultDeviceID) {
      result.addAll(['-s', id]);
    }
    result.addAll(args);
    return result;
  }

  bool _isValidAdbVersion(String adbVersion) {
    // Sample output: 'Android Debug Bridge version 1.0.31'
    Match versionFields =
        new RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion);
    if (versionFields != null) {
      int majorVersion = int.parse(versionFields[1]);
      int minorVersion = int.parse(versionFields[2]);
      int patchVersion = int.parse(versionFields[3]);
      if (majorVersion > 1) {
        return true;
      }
      if (majorVersion == 1 && minorVersion > 0) {
        return true;
      }
      if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 32) {
        return true;
      }
      return false;
    }
    _logging.warning(
        'Unrecognized adb version string $adbVersion. Skipping version check.');
    return true;
  }

  bool _checkForAdb() {
    try {
      String adbVersion = runCheckedSync([adbPath, 'version']);
      if (_isValidAdbVersion(adbVersion)) {
        return true;
      }

      String locatedAdbPath = runCheckedSync(['which', 'adb']);
      _logging.severe('"$locatedAdbPath" is too old. '
          'Please install version 1.0.32 or later.\n'
          'Try setting ANDROID_HOME to the path to your Android SDK install. '
          'Android builds are unavailable.');
    } catch (e, stack) {
      _logging.severe('"adb" not found in \$PATH. '
          'Please install the Android SDK or set ANDROID_HOME '
          'to the path of your Android SDK install.');
      _logging.info(e);
      _logging.info(stack);
    }
    return false;
  }

  bool _checkForSupportedAndroidVersion() {
    try {
      // If the server is automatically restarted, then we get irrelevant
      // output lines like this, which we want to ignore:
      //   adb server is out of date.  killing..
      //   * daemon started successfully *
      runCheckedSync(adbCommandForDevice(['start-server']));

      String ready = runSync(adbCommandForDevice(['shell', 'echo', 'ready']));
      if (ready.trim() != 'ready') {
        _logging.info('Android device not found.');
        return false;
      }

      // Sample output: '22'
      String sdkVersion =
          runCheckedSync(adbCommandForDevice(['shell', 'getprop', 'ro.build.version.sdk']))
              .trimRight();

      int sdkVersionParsed =
          int.parse(sdkVersion, onError: (String source) => null);
      if (sdkVersionParsed == null) {
        _logging.severe('Unexpected response from getprop: "$sdkVersion"');
        return false;
      }
      if (sdkVersionParsed < 16) {
        _logging.severe('The Android version ($sdkVersion) on the target device '
            'is too old. Please use a Jelly Bean (version 16 / 4.1.x) device or later.');
        return false;
      }
      return true;
    } catch (e) {
      _logging.severe('Unexpected failure from adb: ', e);
    }
    return false;
  }

  String _getDeviceSha1Path(ApplicationPackage app) {
    return '/data/local/tmp/sky.${app.id}.sha1';
  }

  String _getDeviceApkSha1(ApplicationPackage app) {
    return runCheckedSync(adbCommandForDevice(['shell', 'cat', _getDeviceSha1Path(app)]));
  }

  String _getSourceSha1(ApplicationPackage app) {
    var sha1 = new SHA1();
    var file = new File(app.localPath);
    sha1.add(file.readAsBytesSync());
    return CryptoUtils.bytesToHex(sha1.close());
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    if (!isConnected()) {
      return false;
    }
    if (runCheckedSync(adbCommandForDevice(['shell', 'pm', 'path', app.id])) ==
        '') {
      _logging.info(
          'TODO(iansf): move this log to the caller. ${app.name} is not on the device. Installing now...');
      return false;
    }
    if (_getDeviceApkSha1(app) != _getSourceSha1(app)) {
      _logging.info(
          'TODO(iansf): move this log to the caller. ${app.name} is out of date. Installing now...');
      return false;
    }
    return true;
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!isConnected()) {
      _logging.info('Android device not connected. Not installing.');
      return false;
    }
    if (!FileSystemEntity.isFileSync(app.localPath)) {
      _logging.severe('"${app.localPath}" does not exist.');
      return false;
    }

    print('Installing ${app.name} on device.');
    runCheckedSync(adbCommandForDevice(['install', '-r', app.localPath]));
    runCheckedSync(adbCommandForDevice(['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]));
    return true;
  }

  void _forwardObservatoryPort() {
    // Set up port forwarding for observatory.
    String portString = 'tcp:$_observatoryPort';
    runCheckedSync(adbCommandForDevice(['forward', portString, portString]));
  }

  bool startBundle(AndroidApk apk, String bundlePath, bool poke, bool checked) {
    if (!FileSystemEntity.isFileSync(bundlePath)) {
      _logging.severe('Cannot find $bundlePath');
      return false;
    }

    if (!poke)
      _forwardObservatoryPort();

    String deviceTmpPath = '/data/local/tmp/dev.flx';
    runCheckedSync(adbCommandForDevice(['push', bundlePath, deviceTmpPath]));
    List<String> cmd = adbCommandForDevice([
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
      '-d', deviceTmpPath,
    ]);
    if (checked)
      cmd.addAll(['--ez', 'enable-checked-mode', 'true']);
    cmd.add(apk.launchActivity);
    runCheckedSync(cmd);
    return true;
  }

  @override
  Future<bool> startApp(ApplicationPackage app) async {
    // Android currently has to be started with startBundle(...).
    assert(false);
    return false;
  }

  Future<bool> stopApp(ApplicationPackage app) async {
    final AndroidApk apk = app;
    runSync(adbCommandForDevice(['shell', 'am', 'force-stop', apk.id]));
    return true;
  }

  @override
  TargetPlatform get platform => TargetPlatform.android;

  void clearLogs() {
    runSync(adbCommandForDevice(['logcat', '-c']));
  }

  Future<int> logs({bool clear: false}) async {
    if (!isConnected()) {
      return 2;
    }

    if (clear) {
      clearLogs();
    }

    return await runCommandAndStreamOutput(adbCommandForDevice([
      'logcat',
      '-v',
      'tag', // Only log the tag and the message
      '-s',
      'flutter:V',
      'chromium:D',
      'ActivityManager:W',
      '*:F',
    ]), prefix: 'android: ');
  }

  void startTracing(AndroidApk apk) {
    runCheckedSync(adbCommandForDevice([
      'shell',
      'am',
      'broadcast',
      '-a',
      '${apk.id}.TRACING_START'
    ]));
  }

  String stopTracing(AndroidApk apk) {
    clearLogs();
    runCheckedSync(adbCommandForDevice([
      'shell',
      'am',
      'broadcast',
      '-a',
      '${apk.id}.TRACING_STOP'
    ]));

    RegExp traceRegExp = new RegExp(r'Saving trace to (\S+)', multiLine: true);
    RegExp completeRegExp = new RegExp(r'Trace complete', multiLine: true);

    String tracePath = null;
    bool isComplete = false;
    while (!isComplete) {
      String logs = runSync(adbCommandForDevice(['logcat', '-d']));
      Match fileMatch = traceRegExp.firstMatch(logs);
      if (fileMatch[1] != null) {
        tracePath = fileMatch[1];
      }
      isComplete = completeRegExp.hasMatch(logs);
    }

    if (tracePath != null) {
      runSync(adbCommandForDevice(['shell', 'run-as', apk.id, 'chmod', '777', tracePath]));
      runSync(adbCommandForDevice(['pull', tracePath]));
      runSync(adbCommandForDevice(['shell', 'rm', tracePath]));
      return path.basename(tracePath);
    }
    _logging.warning('No trace file detected. '
        'Did you remember to start the trace before stopping it?');
    return null;
  }

  @override
  bool isConnected() => _hasValidAndroid;
}

class DeviceStore {
  final AndroidDevice android;
  final IOSDevice iOS;
  final IOSSimulator iOSSimulator;

  List<Device> get all {
    List<Device> result = <Device>[];
    if (android != null)
      result.add(android);
    if (iOS != null)
      result.add(iOS);
    if (iOSSimulator != null)
      result.add(iOSSimulator);
    return result;
  }

  DeviceStore({
    this.android,
    this.iOS,
    this.iOSSimulator
  });

  factory DeviceStore.forConfigs(List<BuildConfiguration> configs) {
    AndroidDevice android;
    IOSDevice iOS;
    IOSSimulator iOSSimulator;

    for (BuildConfiguration config in configs) {
      switch (config.targetPlatform) {
        case TargetPlatform.android:
          assert(android == null);
          List<AndroidDevice> androidDevices = AndroidDevice.getAttachedDevices();
          if (config.deviceId != null) {
            android = androidDevices.firstWhere(
                (AndroidDevice dev) => (dev.id == config.deviceId),
                orElse: () => null);
            if (android == null) {
              print('Warning: Device ID ${config.deviceId} not found');
            }
          } else if (androidDevices.length == 1) {
            android = androidDevices[0];
          } else if (androidDevices.length > 1) {
            print('Warning: Multiple Android devices are connected, but no device ID was specified.');
          }
          break;
        case TargetPlatform.iOS:
          assert(iOS == null);
          iOS = new IOSDevice();
          break;
        case TargetPlatform.iOSSimulator:
          assert(iOSSimulator == null);
          iOSSimulator = new IOSSimulator();
          break;
        case TargetPlatform.mac:
        case TargetPlatform.linux:
          break;
      }
    }

    return new DeviceStore(android: android, iOS: iOS, iOSSimulator: iOSSimulator);
  }
}