blob: c55e0d40170ca379f815b9b13c11a1a806eb58fd [file] [log] [blame]
// 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';
import '../base/common.dart';
import '../base/globals.dart';
import '../base/os.dart';
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../flx.dart' as flx;
import '../toolchain.dart';
import 'android.dart';
const String _defaultAdbPath = 'adb';
// 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';
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;
}
class AndroidDevice extends Device {
AndroidDevice(
String id, {
this.productID,
this.modelID,
this.deviceCodeName,
bool connected
}) : super(id) {
if (connected != null)
_connected = connected;
_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) {
printError('Unable to run on Android.');
}
}
final String productID;
final String modelID;
final String deviceCodeName;
bool _connected;
String _adbPath;
String get adbPath => _adbPath;
bool _hasAdb = false;
bool _hasValidAndroid = false;
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 {
printError('Android SDK not found at $androidHomeDir');
return null;
}
} else {
printError('Android SDK not found. The ANDROID_HOME variable must be set.');
return null;
}
}
List<String> adbCommandForDevice(List<String> args) {
return <String>[adbPath, '-s', id]..addAll(args);
}
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;
}
printError(
'Unrecognized adb version string $adbVersion. Skipping version check.');
return true;
}
bool _checkForAdb() {
try {
String adbVersion = runCheckedSync(<String>[adbPath, 'version']);
if (_isValidAdbVersion(adbVersion)) {
return true;
}
String locatedAdbPath = runCheckedSync(<String>['which', 'adb']);
printError('"$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) {
printError('"adb" not found in \$PATH. '
'Please install the Android SDK or set ANDROID_HOME '
'to the path of your Android SDK install.');
printTrace('$e');
}
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(<String>['start-server']));
String ready = runSync(adbCommandForDevice(<String>['shell', 'echo', 'ready']));
if (ready.trim() != 'ready') {
printTrace('Android device not found.');
return false;
}
// Sample output: '22'
String sdkVersion = runCheckedSync(
adbCommandForDevice(<String>['shell', 'getprop', 'ro.build.version.sdk'])
).trimRight();
int sdkVersionParsed =
int.parse(sdkVersion, onError: (String source) => null);
if (sdkVersionParsed == null) {
printError('Unexpected response from getprop: "$sdkVersion"');
return false;
}
if (sdkVersionParsed < minApiLevel) {
printError(
'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) {
printError('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(<String>['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());
}
String get name => modelID;
@override
bool isAppInstalled(ApplicationPackage app) {
if (!isConnected())
return false;
if (runCheckedSync(adbCommandForDevice(<String>['shell', 'pm', 'path', app.id])) == '') {
printTrace('TODO(iansf): move this log to the caller. ${app.name} is not on the device. Installing now...');
return false;
}
if (_getDeviceApkSha1(app) != _getSourceSha1(app)) {
printTrace(
'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()) {
printTrace('Android device not connected. Not installing.');
return false;
}
if (!FileSystemEntity.isFileSync(app.localPath)) {
printError('"${app.localPath}" does not exist.');
return false;
}
printStatus('Installing ${app.name} on device.');
runCheckedSync(adbCommandForDevice(<String>['install', '-r', app.localPath]));
runCheckedSync(adbCommandForDevice(<String>['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]));
return true;
}
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();
}
try {
// 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');
} catch (e) {
printError('Unable to forward Observatory port $port: $e');
}
}
Future<bool> startBundle(AndroidApk apk, String bundlePath, {
bool checked: true,
bool traceStartup: false,
String route,
bool clearLogs: false,
bool startPaused: false,
int debugPort: observatoryDefaultPort
}) async {
printTrace('$this startBundle');
if (!FileSystemEntity.isFileSync(bundlePath)) {
printError('Cannot find $bundlePath');
return false;
}
await _forwardObservatoryPort(debugPort);
if (clearLogs)
this.clearLogs();
runCheckedSync(adbCommandForDevice(<String>['push', bundlePath, _deviceBundlePath]));
List<String> cmd = adbCommandForDevice(<String>[
'shell', 'am', 'start',
'-a', 'android.intent.action.RUN',
'-d', _deviceBundlePath,
'-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP
]);
if (checked)
cmd.addAll(<String>['--ez', 'enable-checked-mode', 'true']);
if (traceStartup)
cmd.addAll(<String>['--ez', 'trace-startup', 'true']);
if (startPaused)
cmd.addAll(<String>['--ez', 'start-paused', 'true']);
if (route != null)
cmd.addAll(<String>['--es', 'route', route]);
cmd.add(apk.launchActivity);
runCheckedSync(cmd);
return true;
}
@override
Future<bool> startApp(
ApplicationPackage package,
Toolchain toolchain, {
String mainPath,
String route,
bool checked: true,
bool clearLogs: false,
bool startPaused: false,
int debugPort: observatoryDefaultPort,
Map<String, dynamic> platformArgs
}) async {
flx.DirectoryResult buildResult = await flx.buildInTempDir(
toolchain,
mainPath: mainPath
);
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;
}
} finally {
buildResult.dispose();
}
}
Future<bool> stopApp(ApplicationPackage app) async {
final AndroidApk apk = app;
runSync(adbCommandForDevice(<String>['shell', 'am', 'force-stop', apk.id]));
return true;
}
@override
TargetPlatform get platform => TargetPlatform.android;
void clearLogs() {
runSync(adbCommandForDevice(<String>['logcat', '-c']));
}
DeviceLogReader createLogReader() => new _AdbLogReader(this);
void startTracing(AndroidApk apk) {
runCheckedSync(adbCommandForDevice(<String>[
'shell',
'am',
'broadcast',
'-a',
'${apk.id}.TRACING_START'
]));
}
// Return the most recent timestamp in the Android log. The format can be
// passed to logcat's -T option.
String lastLogcatTimestamp() {
String output = runCheckedSync(adbCommandForDevice(<String>['logcat', '-v', 'time', '-t', '1']));
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];
}
Future<String> stopTracing(AndroidApk apk, { String outPath }) async {
// Workaround for logcat -c not always working:
// http://stackoverflow.com/questions/25645012/logcat-on-android-l-not-clearing-after-unplugging-and-reconnecting
String beforeStop = lastLogcatTimestamp();
runCheckedSync(adbCommandForDevice(<String>[
'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 = runCheckedSync(adbCommandForDevice(<String>['logcat', '-d', '-T', beforeStop]));
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);
// 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]
));
return localPath;
}
printError('No trace file detected. '
'Did you remember to start the trace before stopping it?');
return null;
}
bool isConnected() => _connected ?? _hasValidAndroid;
void setConnected(bool value) {
_connected = value;
}
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;
}
}
/// 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 {
runCheckedSync(<String>[adbPath, 'version']);
} catch (e) {
printError('Unable to find adb. Is "adb" in your path?');
return devices;
}
List<String> output = runSync(<String>[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\s+\S+$');
RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$');
// Skip the 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 (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(
deviceID,
productID: productID,
modelID: modelID,
deviceCodeName: deviceCodeName,
connected: true
));
} else if (deviceRegex2.hasMatch(line)) {
Match match = deviceRegex2.firstMatch(line);
String deviceID = match[1];
devices.add(new AndroidDevice(deviceID, connected: true));
} else if (unauthorizedRegex.hasMatch(line)) {
Match match = unauthorizedRegex.firstMatch(line);
String deviceID = match[1];
printError(
'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];
printError('Device $deviceID is offline.');
} else {
printError(
'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 {
printTrace('"adb" not found at\n "$adbPath1" or\n "$adbPath2"\n' +
'using default path "$_defaultAdbPath"');
return _defaultAdbPath;
}
} else {
return _defaultAdbPath;
}
}
/// 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;
}
}