device.dart 29.7 KB
Newer Older
1 2 3 4
// 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.

5
import 'dart:async';
6 7
import 'dart:io';

8
import 'package:crypto/crypto.dart';
9 10
import 'package:path/path.dart' as path;

11
import 'application_package.dart';
12 13
import 'base/logging.dart';
import 'base/process.dart';
14
import 'build_configuration.dart';
15

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

Hixie's avatar
Hixie committed
20 21
  static Device _unique(String id, Device constructor(String id)) {
    return _deviceCache.putIfAbsent(id, () => constructor(id));
22 23
  }

24
  Device._(this.id);
25 26

  /// Install an app package on the current device
27
  bool installApp(ApplicationPackage app);
28 29 30

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

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

35
  TargetPlatform get platform;
36 37 38

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

39 40 41 42 43
  /// 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);
44 45

  String toString() => '$runtimeType $id';
46 47
}

48
class IOSDevice extends Device {
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
  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;

69 70 71
  String _debuggerPath;
  String get debuggerPath => _debuggerPath;

72 73 74
  String _loggerPath;
  String get loggerPath => _loggerPath;

75 76 77
  String _pusherPath;
  String get pusherPath => _pusherPath;

78 79 80 81
  String _name;
  String get name => _name;

  factory IOSDevice({String id, String name}) {
Hixie's avatar
Hixie committed
82
    IOSDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new IOSDevice._(id));
83 84 85 86 87 88 89 90
    device._name = name;
    return device;
  }

  IOSDevice._(String id) : super._(id) {
    _installerPath = _checkForCommand('ideviceinstaller');
    _listerPath = _checkForCommand('idevice_id');
    _informerPath = _checkForCommand('ideviceinfo');
91
    _debuggerPath = _checkForCommand('idevicedebug');
92
    _loggerPath = _checkForCommand('idevicesyslog');
93 94 95 96 97 98 99
    _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.');
100 101 102 103 104 105 106 107 108 109 110
  }

  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;
  }

111
  static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) {
112 113
    String listerPath =
        (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id');
114 115 116 117 118 119 120 121 122
    String output;
    try {
      output = runSync([listerPath, '-l']);
    } catch (e) {
      return [];
    }
    return output.trim()
                 .split('\n')
                 .where((String s) => s != null && s.length > 0);
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
  }

  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) {
141
          logging.severe(macInstructions);
142
        } else if (Platform.isLinux) {
143
          logging.severe(linuxInstructions);
144
        } else {
145
          logging.severe('$command is not available on your platform.');
146 147 148 149 150 151 152 153
        }
      }
      return command;
    });
  }

  @override
  bool installApp(ApplicationPackage app) {
154 155
    try {
      if (id == defaultDeviceID) {
156
        runCheckedSync([installerPath, '-i', app.localPath]);
157
      } else {
158
        runCheckedSync([installerPath, '-u', id, '-i', app.localPath]);
159 160 161 162
      }
      return true;
    } catch (e) {
      return false;
163 164 165 166 167 168
    }
    return false;
  }

  @override
  bool isConnected() {
169
    Iterable<String> ids = _getAttachedDeviceIDs();
170 171 172 173 174 175 176 177 178 179
    for (String id in ids) {
      if (id == this.id || this.id == defaultDeviceID) {
        return true;
      }
    }
    return false;
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
180 181
    try {
      String apps = runCheckedSync([installerPath, '-l']);
182
      if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
        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.
Hixie's avatar
Hixie committed
198 199 200 201 202 203 204
    return await runAndKill(
      [debuggerPath, 'run', app.id],
      new Duration(seconds: 3)
    ).then(
      (_) {
        return true;
      }, onError: (e) {
205
        logging.info('Failure running $debuggerPath: ', e);
Hixie's avatar
Hixie committed
206 207 208
        return false;
      }
    );
209 210 211 212 213
  }

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

217 218 219 220 221 222 223 224
  Future<bool> pushFile(
      ApplicationPackage app, String localFile, String targetFile) async {
    if (Platform.isMacOS) {
      runSync([
        pusherPath,
        '-t',
        '1',
        '--bundle_id',
225
        app.id,
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242
        '--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;
  }

243
  @override
244
  TargetPlatform get platform => TargetPlatform.iOS;
245

246
  /// Note that clear is not supported on iOS at this time.
247 248 249 250
  Future<int> logs({bool clear: false}) async {
    if (!isConnected()) {
      return 2;
    }
Hixie's avatar
Hixie committed
251
    return await runCommandAndStreamOutput([loggerPath],
252
        prefix: 'iOS dev: ', filter: new RegExp(r'.*SkyShell.*'));
253
  }
254 255
}

256
class IOSSimulator extends Device {
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274
  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}) {
Hixie's avatar
Hixie committed
275
    IOSSimulator device = Device._unique(id ?? defaultDeviceID, (String id) => new IOSSimulator._(id));
276 277 278 279 280 281 282 283 284
    device._name = name;
    if (iOSSimulatorPath == null) {
      iOSSimulatorPath = path.join('/Applications', 'iOS Simulator.app',
          'Contents', 'MacOS', 'iOS Simulator');
    }
    device._iOSSimPath = iOSSimulatorPath;
    return device;
  }

Hixie's avatar
Hixie committed
285
  IOSSimulator._(String id) : super._(id);
286 287 288 289 290 291 292 293 294 295 296 297

  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.
298
      logging.warning('Multiple running simulators were detected, '
299 300 301
          'which is not supposed to happen.');
      for (Match m in matches) {
        if (m.groupCount > 0) {
302
          logging.warning('Killing simulator ${m.group(1)}');
303 304 305 306 307 308 309 310 311 312
          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 {
313
      logging.info('No running simulators found');
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
      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) {
358
          logging.info('Timed out waiting for iOS Simulator $id to boot.');
359 360 361
          return false;
        }
        if (!isConnected()) {
362
          logging.info('Waiting for iOS Simulator $id to boot...');
Hixie's avatar
Hixie committed
363
          return await new Future.delayed(new Duration(milliseconds: 500),
364 365 366 367
              () => checkConnection(attempts - 1));
        }
        return true;
      }
Hixie's avatar
Hixie committed
368
      return await checkConnection();
369 370 371 372
    } else {
      try {
        runCheckedSync([xcrunPath, 'simctl', 'boot', id]);
      } catch (e) {
373
        logging.warning('Unable to boot iOS Simulator $id: ', e);
374 375 376 377 378 379 380 381 382 383 384 385 386
        return false;
      }
    }
    return false;
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!isConnected()) {
      return false;
    }
    try {
      if (id == defaultDeviceID) {
387
        runCheckedSync([xcrunPath, 'simctl', 'install', 'booted', app.localPath]);
388
      } else {
389
        runCheckedSync([xcrunPath, 'simctl', 'install', id, app.localPath]);
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429
      }
      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(
430
            [xcrunPath, 'simctl', 'launch', 'booted', app.id]);
431
      } else {
432
        runCheckedSync([xcrunPath, 'simctl', 'launch', id, app.id]);
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
      }
      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;
  }

457
  @override
458
  TargetPlatform get platform => TargetPlatform.iOSSimulator;
459

460 461 462 463 464 465 466 467 468 469 470
  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]);
    }
Hixie's avatar
Hixie committed
471
    return await runCommandAndStreamOutput(['tail', '-f', logFilePath],
472
        prefix: 'iOS sim: ', filter: new RegExp(r'.*SkyShell.*'));
473 474 475
  }
}

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

480
  static final String defaultDeviceID = 'default_android_device';
481

482 483 484 485
  String productID;
  String modelID;
  String deviceCodeName;

486 487
  String _adbPath;
  String get adbPath => _adbPath;
488 489
  bool _hasAdb = false;
  bool _hasValidAndroid = false;
490

491 492 493 494 495
  factory AndroidDevice(
      {String id: null,
      String productID: null,
      String modelID: null,
      String deviceCodeName: null}) {
Hixie's avatar
Hixie committed
496
    AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id));
497 498 499 500 501 502 503 504 505 506
    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 = [];
507
    String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : _getAdbPath();
508 509 510 511

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

516 517 518 519
    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(
520
        r'^(\S+)\s+device\s+.*product:(\S+)\s+model:(\S+)\s+device:(\S+)$');
521 522 523

    // 0149947A0D01500C       device usb:340787200X
    RegExp deviceRegex2 = new RegExp(r'^(\S+)\s+device\s+\S+$');
524
    RegExp unauthorizedRegex = new RegExp(r'^(\S+)\s+unauthorized\s+\S+$');
525
    RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$');
526

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

535 536
      if (deviceRegex1.hasMatch(line)) {
        Match match = deviceRegex1.firstMatch(line);
537 538 539 540 541 542 543 544 545
        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,
546 547 548 549 550 551
            deviceCodeName: deviceCodeName
        ));
      } else if (deviceRegex2.hasMatch(line)) {
        Match match = deviceRegex2.firstMatch(line);
        String deviceID = match[1];
        devices.add(new AndroidDevice(id: deviceID));
552 553 554
      } else if (unauthorizedRegex.hasMatch(line)) {
        Match match = unauthorizedRegex.firstMatch(line);
        String deviceID = match[1];
555
        logging.warning(
556 557 558
          'Device $deviceID is not authorized.\n'
          'You might need to check your device for an authorization dialog.'
        );
559 560 561 562
      } else if (offlineRegex.hasMatch(line)) {
        Match match = offlineRegex.firstMatch(line);
        String deviceID = match[1];
        logging.warning('Device $deviceID is offline.');
563
      } else {
564
        logging.warning(
565 566
          'Unexpected failure parsing device information from adb output:\n'
          '$line\n'
567
          'Please report a bug at https://github.com/flutter/flutter/issues/new');
568 569 570
      }
    }
    return devices;
571 572
  }

573
  AndroidDevice._(id) : super._(id) {
574 575
    _adbPath = _getAdbPath();
    _hasAdb = _checkForAdb();
576

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

582
    if (!_hasAdb || !_hasValidAndroid) {
583
      logging.warning('Unable to run on Android.');
584 585
    }
  }
586

587 588 589 590 591 592 593 594 595 596
  static String getAndroidSdkPath() {
    if (Platform.environment.containsKey('ANDROID_HOME')) {
      String androidHomeDir = Platform.environment['ANDROID_HOME'];
      if (FileSystemEntity.isDirectorySync(
          path.join(androidHomeDir, 'platform-tools'))) {
        return androidHomeDir;
      } else if (FileSystemEntity.isDirectorySync(
          path.join(androidHomeDir, 'sdk', 'platform-tools'))) {
        return path.join(androidHomeDir, 'sdk');
      } else {
597
        logging.warning('Android SDK not found at $androidHomeDir');
598 599 600
        return null;
      }
    } else {
601
      logging.warning('Android SDK not found. The ANDROID_HOME variable must be set.');
602 603 604 605
      return null;
    }
  }

606
  static String _getAdbPath() {
607 608 609 610 611 612
    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)) {
613
        return adbPath1;
614
      } else if (FileSystemEntity.isFileSync(adbPath2)) {
615
        return adbPath2;
616
      } else {
617
        logging.info('"adb" not found at\n  "$adbPath1" or\n  "$adbPath2"\n' +
618
            'using default path "$_ADB_PATH"');
619
        return _ADB_PATH;
620 621
      }
    } else {
622
      return _ADB_PATH;
623 624 625
    }
  }

626 627 628 629 630 631 632 633 634
  List<String> adbCommandForDevice(List<String> args) {
    List<String> result = <String>[adbPath];
    if (id != defaultDeviceID) {
      result.addAll(['-s', id]);
    }
    result.addAll(args);
    return result;
  }

635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653
  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;
    }
654
    logging.warning(
655 656 657 658 659 660 661 662 663 664 665 666
        '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']);
667
      logging.severe('"$locatedAdbPath" is too old. '
668 669 670 671
          '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) {
672
      logging.severe('"adb" not found in \$PATH. '
673 674
          'Please install the Android SDK or set ANDROID_HOME '
          'to the path of your Android SDK install.');
675 676
      logging.info(e);
      logging.info(stack);
677 678 679 680
    }
    return false;
  }

681
  bool _checkForSupportedAndroidVersion() {
682 683 684 685 686
    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 *
687
      runCheckedSync(adbCommandForDevice(['start-server']));
688

689
      String ready = runSync(adbCommandForDevice(['shell', 'echo', 'ready']));
690
      if (ready.trim() != 'ready') {
691
        logging.info('Android device not found.');
692 693 694
        return false;
      }

695 696
      // Sample output: '22'
      String sdkVersion =
697
          runCheckedSync(adbCommandForDevice(['shell', 'getprop', 'ro.build.version.sdk']))
698 699 700 701 702
              .trimRight();

      int sdkVersionParsed =
          int.parse(sdkVersion, onError: (String source) => null);
      if (sdkVersionParsed == null) {
703
        logging.severe('Unexpected response from getprop: "$sdkVersion"');
704 705
        return false;
      }
706
      if (sdkVersionParsed < 16) {
707
        logging.severe('The Android version ($sdkVersion) on the target device '
708
            'is too old. Please use a Jelly Bean (version 16 / 4.1.x) device or later.');
709 710 711
        return false;
      }
      return true;
712
    } catch (e) {
713
      logging.severe('Unexpected failure from adb: ', e);
714 715 716
    }
    return false;
  }
717

718
  String _getDeviceSha1Path(ApplicationPackage app) {
Jeff R. Allen's avatar
Jeff R. Allen committed
719
    return '/data/local/tmp/sky.${app.id}.sha1';
720 721
  }

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

726
  String _getSourceSha1(ApplicationPackage app) {
727 728 729 730
    var sha1 = new SHA1();
    var file = new File(app.localPath);
    sha1.add(file.readAsBytesSync());
    return CryptoUtils.bytesToHex(sha1.close());
731 732 733
  }

  @override
734
  bool isAppInstalled(ApplicationPackage app) {
735 736 737
    if (!isConnected()) {
      return false;
    }
738
    if (runCheckedSync(adbCommandForDevice(['shell', 'pm', 'path', app.id])) ==
739
        '') {
740
      logging.info(
741
          'TODO(iansf): move this log to the caller. ${app.name} is not on the device. Installing now...');
742 743
      return false;
    }
744
    if (_getDeviceApkSha1(app) != _getSourceSha1(app)) {
745
      logging.info(
746
          'TODO(iansf): move this log to the caller. ${app.name} is out of date. Installing now...');
747 748 749 750 751 752
      return false;
    }
    return true;
  }

  @override
753
  bool installApp(ApplicationPackage app) {
754
    if (!isConnected()) {
755
      logging.info('Android device not connected. Not installing.');
756 757
      return false;
    }
758
    if (!FileSystemEntity.isFileSync(app.localPath)) {
759
      logging.severe('"${app.localPath}" does not exist.');
760 761 762
      return false;
    }

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

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

775 776 777
  bool startBundle(AndroidApk apk, String bundlePath, {
    bool poke,
    bool checked,
778
    bool traceStartup,
779 780
    String route
  }) {
781
    logging.fine('$this startBundle');
782

783
    if (!FileSystemEntity.isFileSync(bundlePath)) {
784
      logging.severe('Cannot find $bundlePath');
785 786 787 788 789 790 791
      return false;
    }

    if (!poke)
      _forwardObservatoryPort();

    String deviceTmpPath = '/data/local/tmp/dev.flx';
792 793
    runCheckedSync(adbCommandForDevice(['push', bundlePath, deviceTmpPath]));
    List<String> cmd = adbCommandForDevice([
794 795
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
Jeff R. Allen's avatar
Jeff R. Allen committed
796
      '-d', deviceTmpPath,
797
    ]);
798 799
    if (checked)
      cmd.addAll(['--ez', 'enable-checked-mode', 'true']);
800 801
    if (traceStartup)
        cmd.addAll(['--ez', 'trace-startup', 'true']);
802 803
    if (route != null)
      cmd.addAll(['--es', 'route', route]);
804 805 806 807 808
    cmd.add(apk.launchActivity);
    runCheckedSync(cmd);
    return true;
  }

809
  @override
810
  Future<bool> startApp(ApplicationPackage app) async {
Adam Barth's avatar
Adam Barth committed
811
    // Android currently has to be started with startBundle(...).
812 813 814 815
    assert(false);
    return false;
  }

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

822
  @override
823
  TargetPlatform get platform => TargetPlatform.android;
824

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

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

834 835 836 837
    if (clear) {
      clearLogs();
    }

Hixie's avatar
Hixie committed
838
    return await runCommandAndStreamOutput(adbCommandForDevice([
839 840 841 842
      'logcat',
      '-v',
      'tag', // Only log the tag and the message
      '-s',
843
      'flutter:V',
Devon Carew's avatar
Devon Carew committed
844 845 846
      'chromium:D',
      'ActivityManager:W',
      '*:F',
847
    ]), prefix: 'android: ');
848 849
  }

850
  void startTracing(AndroidApk apk) {
851
    runCheckedSync(adbCommandForDevice([
852 853 854 855
      'shell',
      'am',
      'broadcast',
      '-a',
856
      '${apk.id}.TRACING_START'
857
    ]));
858 859
  }

860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882
  static String _threeDigits(int n) {
    if (n >= 100) return "$n";
    if (n >= 10) return "0$n";
    return "00$n";
  }

  static String _twoDigits(int n) {
    if (n >= 10) return "$n";
    return "0$n";
  }

  static String _logcatDateFormat(DateTime dt) {
    // Doing this manually, instead of using package:intl for simplicity.
    // adb logcat -T wants "%m-%d %H:%M:%S.%3q"
    String m = _twoDigits(dt.month);
    String d = _twoDigits(dt.day);
    String H = _twoDigits(dt.hour);
    String M = _twoDigits(dt.minute);
    String S = _twoDigits(dt.second);
    String q = _threeDigits(dt.millisecond);
    return "$m-$d $H:$M:$S.$q";
  }

883
  String stopTracing(AndroidApk apk, { String outPath: null }) {
884 885 886
    // Workaround for logcat -c not always working:
    // http://stackoverflow.com/questions/25645012/logcat-on-android-l-not-clearing-after-unplugging-and-reconnecting
    String beforeStop = _logcatDateFormat(new DateTime.now());
887
    runCheckedSync(adbCommandForDevice([
888 889 890 891
      'shell',
      'am',
      'broadcast',
      '-a',
892
      '${apk.id}.TRACING_STOP'
893
    ]));
894 895 896 897 898 899 900

    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) {
901
      String logs = runCheckedSync(adbCommandForDevice(['logcat', '-d', '-T', beforeStop]));
902
      Match fileMatch = traceRegExp.firstMatch(logs);
903
      if (fileMatch != null && fileMatch[1] != null) {
904 905 906 907 908 909
        tracePath = fileMatch[1];
      }
      isComplete = completeRegExp.hasMatch(logs);
    }

    if (tracePath != null) {
910
      String localPath = (outPath != null) ? outPath : path.basename(tracePath);
911
      runCheckedSync(adbCommandForDevice(['root']));
912
      runSync(adbCommandForDevice(['shell', 'run-as', apk.id, 'chmod', '777', tracePath]));
913
      runCheckedSync(adbCommandForDevice(['pull', tracePath, localPath]));
914
      runSync(adbCommandForDevice(['shell', 'rm', tracePath]));
915
      return localPath;
916
    }
917
    logging.warning('No trace file detected. '
918 919 920 921
        'Did you remember to start the trace before stopping it?');
    return null;
  }

922 923
  @override
  bool isConnected() => _hasValidAndroid;
924
}
925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953

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) {
954 955
      switch (config.targetPlatform) {
        case TargetPlatform.android:
956
          assert(android == null);
957 958 959 960 961 962 963 964 965 966 967 968 969
          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.');
          }
970
          break;
971
        case TargetPlatform.iOS:
972 973 974
          assert(iOS == null);
          iOS = new IOSDevice();
          break;
975
        case TargetPlatform.iOSSimulator:
976 977 978
          assert(iOSSimulator == null);
          iOSSimulator = new IOSSimulator();
          break;
Ian Hickson's avatar
Ian Hickson committed
979
        case TargetPlatform.mac:
980
        case TargetPlatform.linux:
981 982 983 984 985 986 987
          break;
      }
    }

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