// 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:convert'; import 'dart:io'; import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:path/path.dart' as path; import 'package:web_socket_channel/io.dart'; import '../android/android_sdk.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/os.dart'; import '../base/process.dart'; import '../base/utils.dart'; import '../build_configuration.dart'; import '../device.dart'; import '../flx.dart' as flx; import '../globals.dart'; import '../service_protocol.dart'; import '../toolchain.dart'; import 'adb.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 AndroidDevices extends PollingDeviceDiscovery { AndroidDevices() : super('AndroidDevices'); @override bool get supportsPlatform => true; @override List pollingGetDevices() => getAdbDevices(); } class AndroidDevice extends Device { AndroidDevice( String id, { this.productID, this.modelID, this.deviceCodeName }) : super(id); final String productID; final String modelID; final String deviceCodeName; bool _isLocalEmulator; @override bool get isLocalEmulator { if (_isLocalEmulator == null) { // http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...) String value = runCheckedSync(adbCommandForDevice(['shell', 'getprop', 'ro.product.cpu.abi'])); _isLocalEmulator = value.startsWith('x86'); } return _isLocalEmulator; } _AdbLogReader _logReader; _AndroidDevicePortForwarder _portForwarder; List adbCommandForDevice(List args) { return [androidSdk.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 _checkForSupportedAdbVersion() { if (androidSdk == null) return false; try { String adbVersion = runCheckedSync([androidSdk.adbPath, 'version']); if (_isValidAdbVersion(adbVersion)) return true; printError('The ADB at "${androidSdk.adbPath}" is too old; please install version 1.0.32 or later.'); } catch (error, trace) { printError('Error running ADB: $error', trace); } 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([androidSdk.adbPath, 'start-server']); // 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) { 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(['shell', 'cat', _getDeviceSha1Path(app)])); } String _getSourceSha1(ApplicationPackage app) { File shaFile = new File('${app.localPath}.sha1'); return shaFile.existsSync() ? shaFile.readAsStringSync() : ''; } @override String get name => modelID; @override bool isAppInstalled(ApplicationPackage app) { // This call takes 400ms - 600ms. if (runCheckedSync(adbCommandForDevice(['shell', 'pm', 'path', app.id])).isEmpty) return false; // Check the application SHA. return _getDeviceApkSha1(app) == _getSourceSha1(app); } @override bool installApp(ApplicationPackage app) { if (!FileSystemEntity.isFileSync(app.localPath)) { printError('"${app.localPath}" does not exist.'); return false; } if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion()) return false; runCheckedSync(adbCommandForDevice(['install', '-r', app.localPath])); runCheckedSync(adbCommandForDevice(['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)])); return true; } Future _forwardPort(String service, int devicePort, int port) async { bool portWasZero = (port == null) || (port == 0); try { // Set up port forwarding for observatory. port = await portForwarder.forward(devicePort, hostPort: port); if (portWasZero) printStatus('$service listening on http://127.0.0.1:$port'); } catch (e) { printError('Unable to forward port $port: $e'); } } Future startBundle(AndroidApk apk, String bundlePath, { bool checked: true, bool traceStartup: false, String route, bool clearLogs: false, bool startPaused: false, int observatoryPort: observatoryDefaultPort, int diagnosticPort: diagnosticDefaultPort }) async { printTrace('$this startBundle'); if (!FileSystemEntity.isFileSync(bundlePath)) { printError('Cannot find $bundlePath'); return false; } if (clearLogs) this.clearLogs(); runCheckedSync(adbCommandForDevice(['push', bundlePath, _deviceBundlePath])); ServiceProtocolDiscovery observatoryDiscovery = new ServiceProtocolDiscovery(logReader, ServiceProtocolDiscovery.kObservatoryService); ServiceProtocolDiscovery diagnosticDiscovery = new ServiceProtocolDiscovery(logReader, ServiceProtocolDiscovery.kDiagnosticService); // We take this future here but do not wait for completion until *after* we // start the bundle. Future> scrapeServicePorts = Future.wait( >[observatoryDiscovery.nextPort(), diagnosticDiscovery.nextPort()]); List cmd = adbCommandForDevice([ 'shell', 'am', 'start', '-a', 'android.intent.action.RUN', '-d', _deviceBundlePath, '-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP '--ez', 'enable-background-compilation', 'true', ]); if (checked) cmd.addAll(['--ez', 'enable-checked-mode', 'true']); if (traceStartup) cmd.addAll(['--ez', 'trace-startup', 'true']); if (startPaused) cmd.addAll(['--ez', 'start-paused', 'true']); if (route != null) cmd.addAll(['--es', 'route', route]); cmd.add(apk.launchActivity); String result = runCheckedSync(cmd); // This invocation returns 0 even when it fails. if (result.contains('Error: ')) { printError(result.trim()); return false; } // Wait for the service protocol port here. This will complete once the // device has printed "Observatory is listening on...". printTrace('Waiting for observatory port to be available...'); try { List devicePorts = await scrapeServicePorts.timeout(new Duration(seconds: 12)); int observatoryDevicePort = devicePorts[0]; int diagnosticDevicePort = devicePorts[1]; printTrace('observatory port = $observatoryDevicePort'); await _forwardPort(ServiceProtocolDiscovery.kObservatoryService, observatoryDevicePort, observatoryPort); await _forwardPort(ServiceProtocolDiscovery.kDiagnosticService, diagnosticDevicePort, diagnosticPort); return true; } catch (error) { if (error is TimeoutException) printError('Timed out while waiting for a debug connection.'); else printError('Error waiting for a debug connection: $error'); return false; } } @override Future startApp( ApplicationPackage package, Toolchain toolchain, { String mainPath, String route, bool checked: true, bool clearLogs: false, bool startPaused: false, int observatoryPort: observatoryDefaultPort, int diagnosticPort: diagnosticDefaultPort, Map platformArgs }) async { if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion()) return false; String localBundlePath = await flx.buildFlx( toolchain, mainPath: mainPath, includeRobotoFonts: false ); printTrace('Starting bundle for $this.'); if (await startBundle( package, localBundlePath, checked: checked, traceStartup: platformArgs['trace-startup'], route: route, clearLogs: clearLogs, startPaused: startPaused, observatoryPort: observatoryPort, diagnosticPort: diagnosticPort )) { return true; } else { return false; } } @override Future stopApp(ApplicationPackage app) { List command = adbCommandForDevice(['shell', 'am', 'force-stop', app.id]); return runCommandAndStreamOutput(command).then((int exitCode) => exitCode == 0); } @override TargetPlatform get platform => isLocalEmulator ? TargetPlatform.android_x64 : TargetPlatform.android_arm; @override void clearLogs() { runSync(adbCommandForDevice(['logcat', '-c'])); } @override DeviceLogReader get logReader { if (_logReader == null) _logReader = new _AdbLogReader(this); return _logReader; } @override DevicePortForwarder get portForwarder { if (_portForwarder == null) _portForwarder = new _AndroidDevicePortForwarder(this); return _portForwarder; } /// Return the most recent timestamp in the Android log or `null` if there is /// no available timestamp. The format can be passed to logcat's -T option. String get lastLogcatTimestamp { String output = runCheckedSync(adbCommandForDevice([ '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?.group(0); } Future _connectToObservatory(int observatoryPort) async { Uri uri = new Uri(scheme: 'ws', host: '127.0.0.1', port: observatoryPort, path: 'ws'); WebSocket ws = await WebSocket.connect(uri.toString()); rpc.Client client = new rpc.Client(new IOWebSocketChannel(ws)); client.listen(); return client; } Future startTracing(AndroidApk apk, int observatoryPort) async { rpc.Client client; try { client = await _connectToObservatory(observatoryPort); } catch (e) { printError('Error connecting to observatory: $e'); return; } await client.sendRequest('_setVMTimelineFlags', {'recordedStreams': ['Compiler', 'Dart', 'Embedder', 'GC']} ); await client.sendRequest('_clearVMTimeline'); } Future stopTracing(AndroidApk apk, int observatoryPort, String outPath) async { rpc.Client client; try { client = await _connectToObservatory(observatoryPort); } catch (e) { printError('Error connecting to observatory: $e'); return null; } await client.sendRequest('_setVMTimelineFlags', {'recordedStreams': '[]'}); File localFile; if (outPath != null) { localFile = new File(outPath); } else { localFile = getUniqueFile(Directory.current, 'trace', 'json'); } Map response = await client.sendRequest('_getVMTimeline'); List traceEvents = response['traceEvents']; IOSink sink = localFile.openWrite(); Stream streamIn = new Stream.fromIterable([traceEvents]); Stream> streamOut = new JsonUtf8Encoder().bind(streamIn); await sink.addStream(streamOut); await sink.close(); return path.basename(localFile.path); } @override bool isSupported() => true; Future refreshSnapshot(String activity, String snapshotPath) async { if (!FileSystemEntity.isFileSync(snapshotPath)) { printError('Cannot find $snapshotPath'); return false; } runCheckedSync(adbCommandForDevice(['push', snapshotPath, _deviceSnapshotPath])); List cmd = adbCommandForDevice([ 'shell', 'am', 'start', '-a', 'android.intent.action.RUN', '-d', _deviceBundlePath, '-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP '--es', 'snapshot', _deviceSnapshotPath, activity, ]); runCheckedSync(cmd); return true; } @override bool get supportsScreenshot => true; @override Future takeScreenshot(File outputFile) { const String remotePath = '/data/local/tmp/flutter_screenshot.png'; runCheckedSync(adbCommandForDevice(['shell', 'screencap', '-p', remotePath])); runCheckedSync(adbCommandForDevice(['pull', remotePath, outputFile.path])); runCheckedSync(adbCommandForDevice(['shell', 'rm', remotePath])); return new Future.value(true); } } List getAdbDevices() { String adbPath = getAdbPath(androidSdk); if (adbPath == null) return []; List devices = []; List output = runSync([adbPath, 'devices', '-l']).trim().split('\n'); // 015d172c98400a03 device usb:340787200X product:nakasi model:Nexus_7 device:grouper RegExp deviceRegExLong = new RegExp( r'^(\S+)\s+device\s+.*product:(\S+)\s+model:(\S+)\s+device:(\S+)$'); // 0149947A0D01500C device usb:340787200X // emulator-5612 host features:shell_2 RegExp deviceRegExShort = new RegExp(r'^(\S+)\s+(\S+)\s+\S+$'); for (String line in output) { // Skip lines like: * daemon started successfully * if (line.startsWith('* daemon ')) continue; if (line.startsWith('List of devices')) continue; if (deviceRegExLong.hasMatch(line)) { Match match = deviceRegExLong.firstMatch(line); String deviceID = match[1]; String productID = match[2]; String modelID = match[3]; String deviceCodeName = match[4]; if (modelID != null) modelID = cleanAdbDeviceName(modelID); devices.add(new AndroidDevice( deviceID, productID: productID, modelID: modelID, deviceCodeName: deviceCodeName )); } else if (deviceRegExShort.hasMatch(line)) { Match match = deviceRegExShort.firstMatch(line); String deviceID = match[1]; String deviceState = match[2]; if (deviceState == 'unauthorized') { printError( 'Device $deviceID is not authorized.\n' 'You might need to check your device for an authorization dialog.' ); } else if (deviceState == 'offline') { printError('Device $deviceID is offline.'); } else { devices.add(new AndroidDevice(deviceID)); } } 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; } /// A log reader that logs from `adb logcat`. class _AdbLogReader extends DeviceLogReader { _AdbLogReader(this.device); final AndroidDevice device; final StreamController _linesStreamController = new StreamController.broadcast(); Process _process; StreamSubscription _stdoutSubscription; StreamSubscription _stderrSubscription; @override Stream get lines => _linesStreamController.stream; @override String get name => device.name; @override bool get isReading => _process != null; @override Future get finished => _process != null ? _process.exitCode : new Future.value(0); @override Future start() async { if (_process != null) throw new StateError('_AdbLogReader must be stopped before it can be started.'); // Start the adb logcat process. List args = ['logcat', '-v', 'tag']; String lastTimestamp = device.lastLogcatTimestamp; if (lastTimestamp != null) args.addAll(['-T', lastTimestamp]); args.addAll([ '-s', 'flutter:V', 'FlutterMain:V', 'FlutterView:V', 'AndroidRuntime:W', 'ActivityManager:W', 'System.err:W', '*:F' ]); _process = await runCommand(device.adbCommandForDevice(args)); _stdoutSubscription = _process.stdout.transform(UTF8.decoder) .transform(const LineSplitter()).listen(_onLine); _stderrSubscription = _process.stderr.transform(UTF8.decoder) .transform(const LineSplitter()).listen(_onLine); _process.exitCode.then(_onExit); } @override Future stop() async { if (_process == null) throw new StateError('_AdbLogReader must be started before it can be stopped.'); _stdoutSubscription?.cancel(); _stdoutSubscription = null; _stderrSubscription?.cancel(); _stderrSubscription = null; await _process.kill(); _process = null; } void _onExit(int exitCode) { _stdoutSubscription?.cancel(); _stdoutSubscription = null; _stderrSubscription?.cancel(); _stderrSubscription = null; _process = null; } void _onLine(String line) { // Filter out some noisy ActivityManager notifications. if (line.startsWith('W/ActivityManager: getRunningAppProcesses')) return; _linesStreamController.add(line); } @override int get hashCode => name.hashCode; @override bool operator ==(dynamic other) { if (identical(this, other)) return true; if (other is! _AdbLogReader) return false; return other.device.id == device.id; } } class _AndroidDevicePortForwarder extends DevicePortForwarder { _AndroidDevicePortForwarder(this.device); final AndroidDevice device; static int _extractPort(String portString) { return int.parse(portString.trim(), onError: (_) => null); } @override List get forwardedPorts { final List ports = []; String stdout = runCheckedSync(device.adbCommandForDevice( ['forward', '--list'] )); List lines = LineSplitter.split(stdout).toList(); for (String line in lines) { if (line.startsWith(device.id)) { List splitLine = line.split("tcp:"); // Sanity check splitLine. if (splitLine.length != 3) continue; // Attempt to extract ports. int hostPort = _extractPort(splitLine[1]); int devicePort = _extractPort(splitLine[2]); // Failed, skip. if ((hostPort == null) || (devicePort == null)) continue; ports.add(new ForwardedPort(hostPort, devicePort)); } } return ports; } @override Future forward(int devicePort, { int hostPort }) async { if ((hostPort == null) || (hostPort == 0)) { // Auto select host port. hostPort = await findAvailablePort(); } runCheckedSync(device.adbCommandForDevice( ['forward', 'tcp:$hostPort', 'tcp:$devicePort'] )); return hostPort; } @override Future unforward(ForwardedPort forwardedPort) async { runCheckedSync(device.adbCommandForDevice( ['forward', '--remove', 'tcp:${forwardedPort.hostPort}'] )); } }