device_android.dart 18.1 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
// Copyright 2016 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:path/path.dart' as path;

import '../application_package.dart';
12
import '../base/common.dart';
13
import '../base/globals.dart';
14
import '../base/os.dart';
15 16 17 18 19 20 21
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../flx.dart' as flx;
import '../toolchain.dart';
import 'android.dart';

22 23
const String _defaultAdbPath = 'adb';

24 25 26 27 28 29
// Path where the FLX bundle will be copied on the device.
const String _deviceBundlePath = '/data/local/tmp/dev.flx';

// Path where the snapshot will be copied on the device.
const String _deviceSnapshotPath = '/data/local/tmp/dev_snapshot.bin';

30 31 32 33 34 35 36 37 38 39 40 41 42
class AndroidDeviceDiscovery extends DeviceDiscovery {
  List<Device> _devices = <Device>[];

  bool get supportsPlatform => true;

  Future init() {
    _devices = getAdbDevices();
    return new Future.value();
  }

  List<Device> get devices => _devices;
}

43
class AndroidDevice extends Device {
44 45 46 47 48
  AndroidDevice(
    String id, {
    this.productID,
    this.modelID,
    this.deviceCodeName,
49
    bool connected
50
  }) : super(id) {
51
    if (connected != null)
52
      _connected = connected;
53 54 55 56 57 58 59 60 61 62

    _adbPath = getAdbPath();
    _hasAdb = _checkForAdb();

    // Checking for [minApiName] 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) {
63
      printError('Unable to run on Android.');
64 65 66
    }
  }

67 68 69 70 71 72 73 74 75 76
  final String productID;
  final String modelID;
  final String deviceCodeName;

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

77 78 79 80 81 82 83 84 85 86
  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 {
87
        printError('Android SDK not found at $androidHomeDir');
88 89 90
        return null;
      }
    } else {
91
      printError('Android SDK not found. The ANDROID_HOME variable must be set.');
92 93 94 95 96
      return null;
    }
  }

  List<String> adbCommandForDevice(List<String> args) {
97
    return <String>[adbPath, '-s', id]..addAll(args);
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
  }

  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;
    }
119
    printError(
120 121 122 123 124 125
        'Unrecognized adb version string $adbVersion. Skipping version check.');
    return true;
  }

  bool _checkForAdb() {
    try {
126
      String adbVersion = runCheckedSync(<String>[adbPath, 'version']);
127 128 129 130
      if (_isValidAdbVersion(adbVersion)) {
        return true;
      }

131
      String locatedAdbPath = runCheckedSync(<String>['which', 'adb']);
132
      printError('"$locatedAdbPath" is too old. '
133 134 135
          '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.');
136 137
    } catch (e) {
      printError('"adb" not found in \$PATH. '
138 139
          'Please install the Android SDK or set ANDROID_HOME '
          'to the path of your Android SDK install.');
140
      printTrace('$e');
141 142 143 144 145 146 147 148 149 150
    }
    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 *
151
      runCheckedSync(adbCommandForDevice(<String>['start-server']));
152

153
      String ready = runSync(adbCommandForDevice(<String>['shell', 'echo', 'ready']));
154
      if (ready.trim() != 'ready') {
155
        printTrace('Android device not found.');
156 157 158 159
        return false;
      }

      // Sample output: '22'
160 161 162
      String sdkVersion = runCheckedSync(
        adbCommandForDevice(<String>['shell', 'getprop', 'ro.build.version.sdk'])
      ).trimRight();
163 164 165 166

      int sdkVersionParsed =
          int.parse(sdkVersion, onError: (String source) => null);
      if (sdkVersionParsed == null) {
167
        printError('Unexpected response from getprop: "$sdkVersion"');
168 169 170
        return false;
      }
      if (sdkVersionParsed < minApiLevel) {
171
        printError(
172 173 174 175 176 177
          'The Android version ($sdkVersion) on the target device is too old. Please '
          'use a $minVersionName (version $minApiLevel / $minVersionText) device or later.');
        return false;
      }
      return true;
    } catch (e) {
178
      printError('Unexpected failure from adb: $e');
179 180 181 182 183 184 185 186 187
    }
    return false;
  }

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

  String _getDeviceApkSha1(ApplicationPackage app) {
188
    return runCheckedSync(adbCommandForDevice(<String>['shell', 'cat', _getDeviceSha1Path(app)]));
189 190 191 192 193 194 195 196 197 198 199 200 201
  }

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

  String get name => modelID;

  @override
  bool isAppInstalled(ApplicationPackage app) {
202
    if (!isConnected())
203
      return false;
204 205

    if (runCheckedSync(adbCommandForDevice(<String>['shell', 'pm', 'path', app.id])) == '') {
206
      printTrace('TODO(iansf): move this log to the caller. ${app.name} is not on the device. Installing now...');
207 208 209
      return false;
    }
    if (_getDeviceApkSha1(app) != _getSourceSha1(app)) {
210
      printTrace(
211 212 213 214 215 216 217 218 219
          '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()) {
220
      printTrace('Android device not connected. Not installing.');
221 222 223
      return false;
    }
    if (!FileSystemEntity.isFileSync(app.localPath)) {
224
      printError('"${app.localPath}" does not exist.');
225 226 227
      return false;
    }

228 229 230
    printStatus('Installing ${app.name} on device.');
    runCheckedSync(adbCommandForDevice(<String>['install', '-r', app.localPath]));
    runCheckedSync(adbCommandForDevice(<String>['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]));
231 232 233
    return true;
  }

234 235 236 237 238 239 240 241 242 243
  Future _forwardObservatoryPort(int port) async {
    bool portWasZero = port == 0;

    if (port == 0) {
      // Auto-bind to a port. Set up forwarding for that port. Emit a stdout
      // message similar to the command-line VM, so that tools can parse the output.
      // "Observatory listening on http://127.0.0.1:52111"
      port = await findAvailablePort();
    }

244
    try {
245 246 247 248 249 250 251
      // Set up port forwarding for observatory.
      runCheckedSync(adbCommandForDevice(<String>[
        'forward', 'tcp:$port', 'tcp:$observatoryDefaultPort'
      ]));

      if (portWasZero)
        printStatus('Observatory listening on http://127.0.0.1:$port');
252
    } catch (e) {
253
      printError('Unable to forward Observatory port $port: $e');
254 255 256
    }
  }

257
  Future<bool> startBundle(AndroidApk apk, String bundlePath, {
258 259 260
    bool checked: true,
    bool traceStartup: false,
    String route,
261 262 263 264
    bool clearLogs: false,
    bool startPaused: false,
    int debugPort: observatoryDefaultPort
  }) async {
265
    printTrace('$this startBundle');
266 267

    if (!FileSystemEntity.isFileSync(bundlePath)) {
268
      printError('Cannot find $bundlePath');
269 270 271
      return false;
    }

272
    await _forwardObservatoryPort(debugPort);
273 274 275 276

    if (clearLogs)
      this.clearLogs();

277
    runCheckedSync(adbCommandForDevice(<String>['push', bundlePath, _deviceBundlePath]));
278
    List<String> cmd = adbCommandForDevice(<String>[
279 280
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
281
      '-d', _deviceBundlePath,
282
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
283 284
    ]);
    if (checked)
285
      cmd.addAll(<String>['--ez', 'enable-checked-mode', 'true']);
286
    if (traceStartup)
287
      cmd.addAll(<String>['--ez', 'trace-startup', 'true']);
288
    if (startPaused)
289
      cmd.addAll(<String>['--ez', 'start-paused', 'true']);
290
    if (route != null)
291
      cmd.addAll(<String>['--es', 'route', route]);
292 293 294 295 296 297 298 299 300 301 302 303
    cmd.add(apk.launchActivity);
    runCheckedSync(cmd);
    return true;
  }

  @override
  Future<bool> startApp(
    ApplicationPackage package,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
Devon Carew's avatar
Devon Carew committed
304
    bool clearLogs: false,
305 306
    bool startPaused: false,
    int debugPort: observatoryDefaultPort,
307
    Map<String, dynamic> platformArgs
308 309
  }) async {
    flx.DirectoryResult buildResult = await flx.buildInTempDir(
310 311
      toolchain,
      mainPath: mainPath
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
    );

    printTrace('Starting bundle for $this.');

    try {
      if (await startBundle(
        package,
        buildResult.localBundlePath,
        checked: checked,
        traceStartup: platformArgs['trace-startup'],
        route: route,
        clearLogs: clearLogs,
        startPaused: startPaused,
        debugPort: debugPort
      )) {
        return true;
      } else {
        return false;
330
      }
331 332 333
    } finally {
      buildResult.dispose();
    }
334 335 336 337
  }

  Future<bool> stopApp(ApplicationPackage app) async {
    final AndroidApk apk = app;
338
    runSync(adbCommandForDevice(<String>['shell', 'am', 'force-stop', apk.id]));
339 340 341 342 343 344 345
    return true;
  }

  @override
  TargetPlatform get platform => TargetPlatform.android;

  void clearLogs() {
346
    runSync(adbCommandForDevice(<String>['logcat', '-c']));
347 348
  }

Devon Carew's avatar
Devon Carew committed
349
  DeviceLogReader createLogReader() => new _AdbLogReader(this);
350 351

  void startTracing(AndroidApk apk) {
352
    runCheckedSync(adbCommandForDevice(<String>[
353 354 355 356 357 358 359 360
      'shell',
      'am',
      'broadcast',
      '-a',
      '${apk.id}.TRACING_START'
    ]));
  }

361 362 363
  // Return the most recent timestamp in the Android log.  The format can be
  // passed to logcat's -T option.
  String lastLogcatTimestamp() {
364
    String output = runCheckedSync(adbCommandForDevice(<String>['logcat', '-v', 'time', '-t', '1']));
365 366 367 368

    RegExp timeRegExp = new RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}', multiLine: true);
    Match timeMatch = timeRegExp.firstMatch(output);
    return timeMatch[0];
369 370
  }

371
  Future<String> stopTracing(AndroidApk apk, { String outPath }) async {
372 373
    // Workaround for logcat -c not always working:
    // http://stackoverflow.com/questions/25645012/logcat-on-android-l-not-clearing-after-unplugging-and-reconnecting
374
    String beforeStop = lastLogcatTimestamp();
375
    runCheckedSync(adbCommandForDevice(<String>[
376 377 378 379 380 381 382 383 384 385 386 387 388
      '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) {
389
      String logs = runCheckedSync(adbCommandForDevice(<String>['logcat', '-d', '-T', beforeStop]));
390 391 392 393 394 395 396 397 398
      Match fileMatch = traceRegExp.firstMatch(logs);
      if (fileMatch != null && fileMatch[1] != null) {
        tracePath = fileMatch[1];
      }
      isComplete = completeRegExp.hasMatch(logs);
    }

    if (tracePath != null) {
      String localPath = (outPath != null) ? outPath : path.basename(tracePath);
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415

      // Run cat via ADB to print the captured trace file.  (adb pull will be unable
      // to access the file if it does not have root permissions)
      IOSink catOutput = new File(localPath).openWrite();
      List<String> catCommand = adbCommandForDevice(
          <String>['shell', 'run-as', apk.id, 'cat', tracePath]
      );
      Process catProcess = await Process.start(catCommand[0],
          catCommand.getRange(1, catCommand.length).toList());
      catProcess.stdout.pipe(catOutput);
      int exitCode = await catProcess.exitCode;
      if (exitCode != 0)
        throw 'Error code $exitCode returned when running ${catCommand.join(" ")}';

      runSync(adbCommandForDevice(
          <String>['shell', 'run-as', apk.id, 'rm', tracePath]
      ));
416 417
      return localPath;
    }
418
    printError('No trace file detected. '
419 420 421 422
        'Did you remember to start the trace before stopping it?');
    return null;
  }

423
  bool isConnected() => _connected ?? _hasValidAndroid;
424 425 426 427

  void setConnected(bool value) {
    _connected = value;
  }
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447

  Future<bool> refreshSnapshot(AndroidApk apk, String snapshotPath) async {
    if (!FileSystemEntity.isFileSync(snapshotPath)) {
      printError('Cannot find $snapshotPath');
      return false;
    }

    runCheckedSync(adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath]));

    List<String> cmd = adbCommandForDevice(<String>[
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
      '-d', _deviceBundlePath,
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
      '--es', 'snapshot', _deviceSnapshotPath,
      apk.launchActivity,
    ]);
    runCheckedSync(cmd);
    return true;
  }
448
}
449 450 451 452 453 454 455 456

/// The [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.
List<AndroidDevice> getAdbDevices([AndroidDevice mockAndroid]) {
  List<AndroidDevice> devices = [];
  String adbPath = (mockAndroid != null) ? mockAndroid.adbPath : getAdbPath();

  try {
457
    runCheckedSync(<String>[adbPath, 'version']);
458
  } catch (e) {
459
    printError('Unable to find adb. Is "adb" in your path?');
460 461 462
    return devices;
  }

463
  List<String> output = runSync(<String>[adbPath, 'devices', '-l']).trim().split('\n');
464 465 466 467 468 469 470 471 472 473

  // 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\s+\S+$');
  RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$');

474
  // Skip the first line, which is always 'List of devices attached'.
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496
  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 (line.startsWith('List of devices'))
      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];

      // Convert `Nexus_7` / `Nexus_5X` style names to `Nexus 7` ones.
      if (modelID != null)
        modelID = modelID.replaceAll('_', ' ');

      devices.add(new AndroidDevice(
497 498 499 500 501
        deviceID,
        productID: productID,
        modelID: modelID,
        deviceCodeName: deviceCodeName,
        connected: true
502 503 504 505
      ));
    } else if (deviceRegex2.hasMatch(line)) {
      Match match = deviceRegex2.firstMatch(line);
      String deviceID = match[1];
506
      devices.add(new AndroidDevice(deviceID, connected: true));
507 508 509
    } else if (unauthorizedRegex.hasMatch(line)) {
      Match match = unauthorizedRegex.firstMatch(line);
      String deviceID = match[1];
510
      printError(
511 512 513 514 515 516
        'Device $deviceID is not authorized.\n'
        'You might need to check your device for an authorization dialog.'
      );
    } else if (offlineRegex.hasMatch(line)) {
      Match match = offlineRegex.firstMatch(line);
      String deviceID = match[1];
517
      printError('Device $deviceID is offline.');
518
    } else {
519
      printError(
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537
        '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;
}

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 {
538
      printTrace('"adb" not found at\n  "$adbPath1" or\n  "$adbPath2"\n' +
539 540 541 542 543 544 545
          'using default path "$_defaultAdbPath"');
      return _defaultAdbPath;
    }
  } else {
    return _defaultAdbPath;
  }
}
Devon Carew's avatar
Devon Carew committed
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583

/// A log reader that logs from `adb logcat`. This will have the same output as
/// another copy of [_AdbLogReader], and the two instances will be equivalent.
class _AdbLogReader extends DeviceLogReader {
  _AdbLogReader(this.device);

  final AndroidDevice device;

  String get name => 'Android';

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

    if (clear)
      device.clearLogs();

    return await runCommandAndStreamOutput(device.adbCommandForDevice(<String>[
      'logcat',
      '-v',
      'tag', // Only log the tag and the message
      '-s',
      'flutter:V',
      'ActivityManager:W',
      'System.err:W',
      '*:F',
    ]), prefix: '[Android] ');
  }

  // Intentionally constant; overridden because we've overridden the `operator ==` method below.
  int get hashCode => name.hashCode;

  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    return other is _AdbLogReader;
  }
}