From 5bce2fbdec781b4cd8d272cb1e89dc46461d1899 Mon Sep 17 00:00:00 2001 From: Devon Carew Date: Thu, 21 Jan 2016 11:56:14 -0800 Subject: [PATCH] refactor platform specific code out of device.dart remove device type specific checks --- .../lib/src/android/android.dart | 7 + .../lib/src/android/device_android.dart | 531 +++++++++ .../flutter_tools/lib/src/commands/apk.dart | 17 +- .../lib/src/commands/daemon.dart | 1 + .../flutter_tools/lib/src/commands/init.dart | 4 +- .../flutter_tools/lib/src/commands/list.dart | 5 +- .../flutter_tools/lib/src/commands/start.dart | 47 +- .../flutter_tools/lib/src/commands/trace.dart | 2 +- packages/flutter_tools/lib/src/device.dart | 1011 +---------------- packages/flutter_tools/lib/src/flx.dart | 44 +- .../flutter_tools/lib/src/ios/device_ios.dart | 528 +++++++++ .../test/android_device_test.dart | 2 +- packages/flutter_tools/test/src/mocks.dart | 2 + 13 files changed, 1150 insertions(+), 1051 deletions(-) create mode 100644 packages/flutter_tools/lib/src/android/android.dart create mode 100644 packages/flutter_tools/lib/src/android/device_android.dart create mode 100644 packages/flutter_tools/lib/src/ios/device_ios.dart diff --git a/packages/flutter_tools/lib/src/android/android.dart b/packages/flutter_tools/lib/src/android/android.dart new file mode 100644 index 00000000000..78f4ba373cc --- /dev/null +++ b/packages/flutter_tools/lib/src/android/android.dart @@ -0,0 +1,7 @@ +// 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. + +const int minApiLevel = 16; +const String minVersionName = 'Jelly Bean'; +const String minVersionText = '4.1.x'; diff --git a/packages/flutter_tools/lib/src/android/device_android.dart b/packages/flutter_tools/lib/src/android/device_android.dart new file mode 100644 index 00000000000..71ffbfcc5ec --- /dev/null +++ b/packages/flutter_tools/lib/src/android/device_android.dart @@ -0,0 +1,531 @@ +// 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/logging.dart'; +import '../base/process.dart'; +import '../build_configuration.dart'; +import '../device.dart'; +import '../flx.dart' as flx; +import '../toolchain.dart'; +import 'android.dart'; + +class AndroidDevice extends Device { + static const String _defaultAdbPath = 'adb'; + static const int _observatoryPort = 8181; + + static final String defaultDeviceID = 'default_android_device'; + + String productID; + String modelID; + String deviceCodeName; + + bool _connected; + 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, + bool connected + }) { + AndroidDevice device = Device.unique(id ?? defaultDeviceID, (String id) => new AndroidDevice.fromId(id)); + device.productID = productID; + device.modelID = modelID; + device.deviceCodeName = deviceCodeName; + if (connected != null) + device._connected = connected; + return device; + } + + /// This constructor is intended as protected access; prefer [AndroidDevice]. + AndroidDevice.fromId(id) : super.fromId(id) { + _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) { + logging.warning('Unable to run on Android.'); + } + } + + /// 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 getAttachedDevices([AndroidDevice mockAndroid]) { + List 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 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\s+\S+$'); + RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$'); + + // 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 (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]; + + 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 if (offlineRegex.hasMatch(line)) { + Match match = offlineRegex.firstMatch(line); + String deviceID = match[1]; + logging.warning('Device $deviceID is offline.'); + } 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; + } + + 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 { + logging.warning('Android SDK not found at $androidHomeDir'); + return null; + } + } else { + logging.warning('Android SDK not found. The ANDROID_HOME variable must be set.'); + return null; + } + } + + 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 "$_defaultAdbPath"'); + return _defaultAdbPath; + } + } else { + return _defaultAdbPath; + } + } + + List adbCommandForDevice(List args) { + List result = [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 < minApiLevel) { + logging.severe( + '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) { + 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()); + } + + String get name => modelID; + + @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'; + try { + runCheckedSync(adbCommandForDevice(['forward', portString, portString])); + } catch (e) { + logging.warning('Unable to forward observatory port ($_observatoryPort):\n$e'); + } + } + + bool startBundle(AndroidApk apk, String bundlePath, { + bool poke: false, + bool checked: true, + bool traceStartup: false, + String route, + bool clearLogs: false + }) { + logging.fine('$this startBundle'); + + if (!FileSystemEntity.isFileSync(bundlePath)) { + logging.severe('Cannot find $bundlePath'); + return false; + } + + if (!poke) + _forwardObservatoryPort(); + + if (clearLogs) + this.clearLogs(); + + String deviceTmpPath = '/data/local/tmp/dev.flx'; + runCheckedSync(adbCommandForDevice(['push', bundlePath, deviceTmpPath])); + List cmd = adbCommandForDevice([ + 'shell', 'am', 'start', + '-a', 'android.intent.action.RUN', + '-d', deviceTmpPath, + ]); + if (checked) + cmd.addAll(['--ez', 'enable-checked-mode', 'true']); + if (traceStartup) + cmd.addAll(['--ez', 'trace-startup', 'true']); + if (route != null) + cmd.addAll(['--es', 'route', route]); + cmd.add(apk.launchActivity); + runCheckedSync(cmd); + return true; + } + + @override + Future startApp( + ApplicationPackage package, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + Map platformArgs + }) { + return flx.buildInTempDir( + toolchain, + mainPath: mainPath + ).then((flx.DirectoryResult buildResult) { + logging.fine('Starting bundle for $this.'); + + try { + if (startBundle( + package, + buildResult.localBundlePath, + poke: platformArgs['poke'], + checked: checked, + traceStartup: platformArgs['trace-startup'], + route: route, + clearLogs: platformArgs['clear-logs'] + )) { + return true; + } else { + return false; + } + } finally { + buildResult.dispose(); + } + }); + } + + Future 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 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', + 'ActivityManager:W', + 'System.err:W', + '*:F', + ]), prefix: 'android: '); + } + + void startTracing(AndroidApk apk) { + runCheckedSync(adbCommandForDevice([ + 'shell', + 'am', + 'broadcast', + '-a', + '${apk.id}.TRACING_START' + ])); + } + + 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"; + } + + // TODO(eseidel): This is fragile, there must be a better way! + DateTime timeOnDevice() { + // Careful: Android's date command is super-lame, any arguments are taken as + // attempts to set the timezone and will screw your device. + String output = runCheckedSync(adbCommandForDevice(['shell', 'date'])).trim(); + // format: Fri Dec 18 13:22:07 PST 2015 + // intl doesn't handle timezones: https://github.com/dart-lang/intl/issues/93 + // So we use the local date command to parse dates for us. + String seconds = runSync(['date', '--date', output, '+%s']); + // Although '%s' is supposed to be UTC, date appears to be ignoring the + // timezone in the passed string, so using isUTC: false here. + return new DateTime.fromMillisecondsSinceEpoch(int.parse(seconds) * 1000, isUtc: false); + } + + String stopTracing(AndroidApk apk, { String outPath: null }) { + // 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(timeOnDevice()); + 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 = runCheckedSync(adbCommandForDevice(['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); + runCheckedSync(adbCommandForDevice(['root'])); + runSync(adbCommandForDevice(['shell', 'run-as', apk.id, 'chmod', '777', tracePath])); + runCheckedSync(adbCommandForDevice(['pull', tracePath, localPath])); + runSync(adbCommandForDevice(['shell', 'rm', tracePath])); + return localPath; + } + logging.warning('No trace file detected. ' + 'Did you remember to start the trace before stopping it?'); + return null; + } + + bool isConnected() => _connected != null ? _connected : _hasValidAndroid; + + void setConnected(bool value) { + _connected = value; + } +} diff --git a/packages/flutter_tools/lib/src/commands/apk.dart b/packages/flutter_tools/lib/src/commands/apk.dart index c894d0bea89..b6f8e533173 100644 --- a/packages/flutter_tools/lib/src/commands/apk.dart +++ b/packages/flutter_tools/lib/src/commands/apk.dart @@ -9,12 +9,12 @@ import 'dart:io'; import 'package:path/path.dart' as path; import 'package:yaml/yaml.dart'; +import '../android/device_android.dart'; import '../artifacts.dart'; import '../base/file_system.dart'; import '../base/logging.dart'; import '../base/process.dart'; import '../build_configuration.dart'; -import '../device.dart'; import '../flx.dart' as flx; import '../runner/flutter_command.dart'; import 'start.dart'; @@ -392,16 +392,13 @@ class ApkCommand extends FlutterCommand { String mainPath = findMainDartFile(argResults['target']); // Build the FLX. - int result; - await flx.buildInTempDir( - toolchain, - mainPath: mainPath, - onBundleAvailable: (String localBundlePath) { - result = _buildApk(components, localBundlePath); - } - ); + flx.DirectoryResult buildResult = await flx.buildInTempDir(toolchain, mainPath: mainPath); - return result; + try { + return _buildApk(components, buildResult.localBundlePath); + } finally { + buildResult.dispose(); + } } } } diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index 3609c506e80..793d6afbe66 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:io'; import '../android/adb.dart'; +import '../android/device_android.dart'; import '../base/logging.dart'; import '../device.dart'; import '../runner/flutter_command.dart'; diff --git a/packages/flutter_tools/lib/src/commands/init.dart b/packages/flutter_tools/lib/src/commands/init.dart index e4085940b13..df6d2076856 100644 --- a/packages/flutter_tools/lib/src/commands/init.dart +++ b/packages/flutter_tools/lib/src/commands/init.dart @@ -9,10 +9,10 @@ import 'package:args/command_runner.dart'; import 'package:mustache4dart/mustache4dart.dart' as mustache; import 'package:path/path.dart' as path; +import '../android/android.dart' as android; import '../artifacts.dart'; import '../base/logging.dart'; import '../base/process.dart'; -import '../device.dart'; class InitCommand extends Command { final String name = 'init'; @@ -247,7 +247,7 @@ final String _apkManifest = ''' - + diff --git a/packages/flutter_tools/lib/src/commands/list.dart b/packages/flutter_tools/lib/src/commands/list.dart index 4347b30429e..f2f1feafefe 100644 --- a/packages/flutter_tools/lib/src/commands/list.dart +++ b/packages/flutter_tools/lib/src/commands/list.dart @@ -4,7 +4,8 @@ import 'dart:async'; -import '../device.dart'; +import '../android/device_android.dart'; +import '../ios/device_ios.dart'; import '../runner/flutter_command.dart'; class ListCommand extends FlutterCommand { @@ -29,6 +30,8 @@ class ListCommand extends FlutterCommand { if (details) print('Android Devices:'); + // TODO(devoncarew): We should have a more generic mechanism for device discovery. + // DeviceDiscoveryService? DeviceDiscoveryParticipant? for (AndroidDevice device in AndroidDevice.getAttachedDevices(devices.android)) { if (details) { print('${device.id}\t' diff --git a/packages/flutter_tools/lib/src/commands/start.dart b/packages/flutter_tools/lib/src/commands/start.dart index 862327fc8bb..2983ed40e86 100644 --- a/packages/flutter_tools/lib/src/commands/start.dart +++ b/packages/flutter_tools/lib/src/commands/start.dart @@ -9,9 +9,7 @@ import 'package:path/path.dart' as path; import '../application_package.dart'; import '../base/logging.dart'; -import '../build_configuration.dart'; import '../device.dart'; -import '../flx.dart' as flx; import '../runner/flutter_command.dart'; import '../toolchain.dart'; import 'install.dart'; @@ -138,31 +136,28 @@ Future startApp( logging.fine('Running build command for $device.'); - if (device.platform == TargetPlatform.android) { - await flx.buildInTempDir( - toolchain, - mainPath: mainPath, - onBundleAvailable: (String localBundlePath) { - logging.fine('Starting bundle for $device.'); - final AndroidDevice androidDevice = device; // https://github.com/flutter/flutter/issues/1035 - if (androidDevice.startBundle(package, localBundlePath, - poke: poke, - checked: checked, - traceStartup: traceStartup, - route: route, - clearLogs: clearLogs - )) { - startedSomething = true; - } - } - ); + Map platformArgs = {}; + + if (poke != null) + platformArgs['poke'] = poke; + if (traceStartup != null) + platformArgs['trace-startup'] = traceStartup; + if (clearLogs != null) + platformArgs['clear-logs'] = clearLogs; + + bool result = await device.startApp( + package, + toolchain, + mainPath: mainPath, + route: route, + checked: checked, + platformArgs: platformArgs + ); + + if (!result) { + logging.severe('Could not start \'${package.name}\' on \'${device.id}\''); } else { - bool result = await device.startApp(package); - if (!result) { - logging.severe('Could not start \'${package.name}\' on \'${device.id}\''); - } else { - startedSomething = true; - } + startedSomething = true; } } diff --git a/packages/flutter_tools/lib/src/commands/trace.dart b/packages/flutter_tools/lib/src/commands/trace.dart index 04f006ad3f1..760f223df30 100644 --- a/packages/flutter_tools/lib/src/commands/trace.dart +++ b/packages/flutter_tools/lib/src/commands/trace.dart @@ -4,9 +4,9 @@ import 'dart:async'; +import '../android/device_android.dart'; import '../application_package.dart'; import '../base/logging.dart'; -import '../device.dart'; import '../runner/flutter_command.dart'; class TraceCommand extends FlutterCommand { diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index b5d56a0c568..83488b6b932 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -3,21 +3,19 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:path/path.dart' as path; +import 'android/device_android.dart'; import 'application_package.dart'; import 'base/logging.dart'; -import 'base/process.dart'; import 'build_configuration.dart'; +import 'ios/device_ios.dart'; +import 'toolchain.dart'; abstract class Device { final String id; static Map _deviceCache = {}; - static Device _unique(String id, Device constructor(String id)) { + static Device unique(String id, Device constructor(String id)) { return _deviceCache.putIfAbsent(id, () => constructor(id)); } @@ -25,7 +23,7 @@ abstract class Device { _deviceCache.remove(id); } - Device._(this.id); + Device.fromId(this.id); String get name; @@ -42,984 +40,25 @@ abstract class Device { Future logs({bool clear: false}); - /// Start an app package on the current device - Future startApp(ApplicationPackage app); + /// Start an app package on the current device. + /// + /// [platformArgs] allows callers to pass platform-specific arguments to the + /// start call. + Future startApp( + ApplicationPackage package, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + Map platformArgs + }); - /// Stop an app package on the current device + /// Stop an app package on the current device. Future stopApp(ApplicationPackage app); String toString() => '$runtimeType $id'; } -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'); - } - - static List getAttachedDevices([IOSDevice mockIOS]) { - List devices = []; - for (String id in _getAttachedDeviceIDs(mockIOS)) { - String name = _getDeviceName(id, mockIOS); - devices.add(new IOSDevice(id: id, name: name)); - } - return devices; - } - - static Iterable _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 _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 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, '--list-apps']); - if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { - return true; - } - } catch (e) { - return false; - } - return false; - } - - @override - Future startApp(ApplicationPackage app) async { - logging.fine("Attempting to build and install ${app.name} on $id"); - - // Step 1: Install the precompiled application if necessary - bool buildResult = await _buildIOSXcodeProject(app, true); - - if (!buildResult) { - logging.severe('Could not build the precompiled application for the device'); - return false; - } - - // Step 2: Check that the application exists at the specified path - Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app')); - - bool bundleExists = await bundle.exists(); - if (!bundleExists) { - logging.severe('Could not find the built application bundle at ${bundle.path}'); - return false; - } - - // Step 3: Attempt to install the application on the device - int installationResult = await runCommandAndStreamOutput([ - '/usr/bin/env', - 'ios-deploy', - '--id', - id, - '--bundle', - bundle.path, - ]); - - if (installationResult != 0) { - logging.severe('Could not install ${bundle.path} on $id'); - return false; - } - - logging.fine('Installation successful'); - return true; - } - - @override - Future stopApp(ApplicationPackage app) async { - // Currently we don't have a way to stop an app running on iOS. - return false; - } - - Future 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 { - return false; - } - return false; - } - - @override - TargetPlatform get platform => TargetPlatform.iOS; - - /// Note that clear is not supported on iOS at this time. - Future 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 _IOSSimulatorInfo _getRunningSimulatorInfo([IOSSimulator mockIOS]) { - String xcrunPath = mockIOS != null ? mockIOS.xcrunPath : _xcrunPath; - String output = runCheckedSync([xcrunPath, 'simctl', 'list', 'devices']); - - Match match; - // iPhone 6s Plus (8AC808E1-6BAE-4153-BBC5-77F83814D414) (Booted) - Iterable matches = new RegExp( - r'[\W]*(.*) \(([^\)]+)\) \(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 match in matches) { - if (match.groupCount > 0) { - // TODO: We're killing simulator devices inside an accessor method; - // we probably shouldn't be changing state here. - logging.warning('Killing simulator ${match.group(1)}'); - runSync([xcrunPath, 'simctl', 'shutdown', match.group(2)]); - } - } - } else if (matches.length == 1) { - match = matches.first; - } - - if (match != null && match.groupCount > 0) { - return new _IOSSimulatorInfo(match.group(2), match.group(1)); - } else { - logging.info('No running simulators found'); - return null; - } - } - - String _getSimulatorPath() { - String deviceID = id == defaultDeviceID ? _getRunningSimulatorInfo()?.id : 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 getAttachedDevices([IOSSimulator mockIOS]) { - List devices = []; - _IOSSimulatorInfo deviceInfo = _getRunningSimulatorInfo(mockIOS); - if (deviceInfo != null) - devices.add(new IOSSimulator(id: deviceInfo.id, name: deviceInfo.name)); - return devices; - } - - Future boot() async { - if (!Platform.isMacOS) - return false; - if (isConnected()) - return true; - if (id == defaultDeviceID) { - runDetached([iOSSimPath]); - Future 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; - _IOSSimulatorInfo deviceInfo = _getRunningSimulatorInfo(); - if (deviceInfo == null) { - return false; - } else if (deviceInfo.id == defaultDeviceID) { - return true; - } else { - return _getRunningSimulatorInfo()?.id == id; - } - } - - @override - bool isAppInstalled(ApplicationPackage app) { - try { - String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); - return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); - } catch (e) { - return false; - } - } - - @override - Future startApp(ApplicationPackage app) async { - logging.fine('Building ${app.name} for $id'); - - // Step 1: Build the Xcode project - bool buildResult = await _buildIOSXcodeProject(app, false); - if (!buildResult) { - logging.severe('Could not build the application for the simulator'); - return false; - } - - // Step 2: Assert that the Xcode project was successfully built - Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app')); - bool bundleExists = await bundle.exists(); - if (!bundleExists) { - logging.severe('Could not find the built application bundle at ${bundle.path}'); - return false; - } - - // Step 3: Install the updated bundle to the simulator - int installResult = await runCommandAndStreamOutput([ - xcrunPath, - 'simctl', - 'install', - id == defaultDeviceID ? 'booted' : id, - path.absolute(bundle.path) - ]); - - if (installResult != 0) { - logging.severe('Could not install the application bundle on the simulator'); - return false; - } - - // Step 4: Launch the updated application in the simulator - int launchResult = await runCommandAndStreamOutput([ - xcrunPath, - 'simctl', - 'launch', - id == defaultDeviceID ? 'booted' : id, - app.id - ]); - - if (launchResult != 0) { - logging.severe('Could not launch the freshly installed application on the simulator'); - return false; - } - - logging.fine('Successfully started ${app.name} on $id'); - return true; - } - - @override - Future stopApp(ApplicationPackage app) async { - // Currently we don't have a way to stop an app running on iOS. - return false; - } - - Future 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 logs({bool clear: false}) async { - if (!isConnected()) - return 2; - - String homeDirectory = path.absolute(Platform.environment['HOME']); - String simulatorDeviceID = _getRunningSimulatorInfo().id; - 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 _IOSSimulatorInfo { - final String id; - final String name; - - _IOSSimulatorInfo(this.id, this.name); -} - -class AndroidDevice extends Device { - static const int minApiLevel = 16; - static const String minVersionName = 'Jelly Bean'; - static const String minVersionText = '4.1.x'; - - static const String _defaultAdbPath = 'adb'; - static const int _observatoryPort = 8181; - - static final String defaultDeviceID = 'default_android_device'; - - String productID; - String modelID; - String deviceCodeName; - - bool _connected; - 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, - bool connected - }) { - AndroidDevice device = Device._unique(id ?? defaultDeviceID, (String id) => new AndroidDevice._(id)); - device.productID = productID; - device.modelID = modelID; - device.deviceCodeName = deviceCodeName; - if (connected != null) - device._connected = connected; - 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 getAttachedDevices([AndroidDevice mockAndroid]) { - List 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 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\s+\S+$'); - RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$'); - - // 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 (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]; - - 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 if (offlineRegex.hasMatch(line)) { - Match match = offlineRegex.firstMatch(line); - String deviceID = match[1]; - logging.warning('Device $deviceID is offline.'); - } 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 [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) { - logging.warning('Unable to run on Android.'); - } - } - - 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 { - logging.warning('Android SDK not found at $androidHomeDir'); - return null; - } - } else { - logging.warning('Android SDK not found. The ANDROID_HOME variable must be set.'); - return null; - } - } - - 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 "$_defaultAdbPath"'); - return _defaultAdbPath; - } - } else { - return _defaultAdbPath; - } - } - - List adbCommandForDevice(List args) { - List result = [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 < minApiLevel) { - logging.severe( - '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) { - 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()); - } - - String get name => modelID; - - @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'; - try { - runCheckedSync(adbCommandForDevice(['forward', portString, portString])); - } catch (e) { - logging.warning('Unable to forward observatory port ($_observatoryPort):\n$e'); - } - } - - bool startBundle(AndroidApk apk, String bundlePath, { - bool poke: false, - bool checked: true, - bool traceStartup: false, - String route, - bool clearLogs: false - }) { - logging.fine('$this startBundle'); - - if (!FileSystemEntity.isFileSync(bundlePath)) { - logging.severe('Cannot find $bundlePath'); - return false; - } - - if (!poke) - _forwardObservatoryPort(); - - if (clearLogs) - this.clearLogs(); - - String deviceTmpPath = '/data/local/tmp/dev.flx'; - runCheckedSync(adbCommandForDevice(['push', bundlePath, deviceTmpPath])); - List cmd = adbCommandForDevice([ - 'shell', 'am', 'start', - '-a', 'android.intent.action.RUN', - '-d', deviceTmpPath, - ]); - if (checked) - cmd.addAll(['--ez', 'enable-checked-mode', 'true']); - if (traceStartup) - cmd.addAll(['--ez', 'trace-startup', 'true']); - if (route != null) - cmd.addAll(['--es', 'route', route]); - cmd.add(apk.launchActivity); - runCheckedSync(cmd); - return true; - } - - @override - Future startApp(ApplicationPackage app) async { - // Android currently has to be started with startBundle(...). - assert(false); - return false; - } - - Future 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 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', - 'ActivityManager:W', - 'System.err:W', - '*:F', - ]), prefix: 'android: '); - } - - void startTracing(AndroidApk apk) { - runCheckedSync(adbCommandForDevice([ - 'shell', - 'am', - 'broadcast', - '-a', - '${apk.id}.TRACING_START' - ])); - } - - 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"; - } - - // TODO(eseidel): This is fragile, there must be a better way! - DateTime timeOnDevice() { - // Careful: Android's date command is super-lame, any arguments are taken as - // attempts to set the timezone and will screw your device. - String output = runCheckedSync(adbCommandForDevice(['shell', 'date'])).trim(); - // format: Fri Dec 18 13:22:07 PST 2015 - // intl doesn't handle timezones: https://github.com/dart-lang/intl/issues/93 - // So we use the local date command to parse dates for us. - String seconds = runSync(['date', '--date', output, '+%s']); - // Although '%s' is supposed to be UTC, date appears to be ignoring the - // timezone in the passed string, so using isUTC: false here. - return new DateTime.fromMillisecondsSinceEpoch(int.parse(seconds) * 1000, isUtc: false); - } - - String stopTracing(AndroidApk apk, { String outPath: null }) { - // 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(timeOnDevice()); - 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 = runCheckedSync(adbCommandForDevice(['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); - runCheckedSync(adbCommandForDevice(['root'])); - runSync(adbCommandForDevice(['shell', 'run-as', apk.id, 'chmod', '777', tracePath])); - runCheckedSync(adbCommandForDevice(['pull', tracePath, localPath])); - runSync(adbCommandForDevice(['shell', 'rm', tracePath])); - return localPath; - } - logging.warning('No trace file detected. ' - 'Did you remember to start the trace before stopping it?'); - return null; - } - - bool isConnected() => _connected != null ? _connected : _hasValidAndroid; - - void setConnected(bool value) { - _connected = value; - } -} - class DeviceStore { final AndroidDevice android; final IOSDevice iOS; @@ -1098,17 +137,3 @@ class DeviceStore { return new DeviceStore(android: android, iOS: iOS, iOSSimulator: iOSSimulator); } } - -Future _buildIOSXcodeProject(ApplicationPackage app, bool isDevice) async { - List command = [ - '/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release' - ]; - - if (!isDevice) { - command.addAll(['-sdk', 'iphonesimulator']); - } - - int result = await runCommandAndStreamOutput(command, - workingDirectory: app.localPath); - return result == 0; -} diff --git a/packages/flutter_tools/lib/src/flx.dart b/packages/flutter_tools/lib/src/flx.dart index fccc96ee61b..b7daa5f2bd7 100644 --- a/packages/flutter_tools/lib/src/flx.dart +++ b/packages/flutter_tools/lib/src/flx.dart @@ -114,28 +114,38 @@ ArchiveFile _createSnapshotFile(String snapshotPath) { return new ArchiveFile(_kSnapshotKey, content.length, content); } -Future buildInTempDir( +/// Build the flx in a temp dir and return `localBundlePath` on success. +Future buildInTempDir( Toolchain toolchain, { - String mainPath: defaultMainPath, - void onBundleAvailable(String bundlePath) + String mainPath: defaultMainPath }) async { int result; Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools'); - try { - String localBundlePath = path.join(tempDir.path, 'app.flx'); - String localSnapshotPath = path.join(tempDir.path, 'snapshot_blob.bin'); - result = await build( - toolchain, - snapshotPath: localSnapshotPath, - outputPath: localBundlePath, - mainPath: mainPath - ); - if (result == 0) - onBundleAvailable(localBundlePath); - } finally { - tempDir.deleteSync(recursive: true); + String localBundlePath = path.join(tempDir.path, 'app.flx'); + String localSnapshotPath = path.join(tempDir.path, 'snapshot_blob.bin'); + result = await build( + toolchain, + snapshotPath: localSnapshotPath, + outputPath: localBundlePath, + mainPath: mainPath + ); + if (result == 0) + return new DirectoryResult(tempDir, localBundlePath); + else + throw result; +} + +/// The result from [buildInTempDir]. Note that this object should be disposed after use. +class DirectoryResult { + final Directory directory; + final String localBundlePath; + + DirectoryResult(this.directory, this.localBundlePath); + + /// Call this to delete the temporary directory. + void dispose() { + directory.deleteSync(recursive: true); } - return result; } Future build( diff --git a/packages/flutter_tools/lib/src/ios/device_ios.dart b/packages/flutter_tools/lib/src/ios/device_ios.dart new file mode 100644 index 00000000000..7c1b19f67ad --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/device_ios.dart @@ -0,0 +1,528 @@ +// 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:path/path.dart' as path; + +import '../application_package.dart'; +import '../base/logging.dart'; +import '../base/process.dart'; +import '../build_configuration.dart'; +import '../device.dart'; +import '../toolchain.dart'; + +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.fromId(id)); + device._name = name; + return device; + } + + IOSDevice.fromId(String id) : super.fromId(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'); + } + + static List getAttachedDevices([IOSDevice mockIOS]) { + List devices = []; + for (String id in _getAttachedDeviceIDs(mockIOS)) { + String name = _getDeviceName(id, mockIOS); + devices.add(new IOSDevice(id: id, name: name)); + } + return devices; + } + + static Iterable _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 _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 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, '--list-apps']); + if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { + return true; + } + } catch (e) { + return false; + } + return false; + } + + @override + Future startApp( + ApplicationPackage app, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + Map platformArgs + }) async { + // TODO: Use checked, mainPath, route + logging.fine('Building ${app.name} for $id'); + + // Step 1: Install the precompiled application if necessary + bool buildResult = await _buildIOSXcodeProject(app, true); + + if (!buildResult) { + logging.severe('Could not build the precompiled application for the device'); + return false; + } + + // Step 2: Check that the application exists at the specified path + Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app')); + + bool bundleExists = await bundle.exists(); + if (!bundleExists) { + logging.severe('Could not find the built application bundle at ${bundle.path}'); + return false; + } + + // Step 3: Attempt to install the application on the device + int installationResult = await runCommandAndStreamOutput([ + '/usr/bin/env', + 'ios-deploy', + '--id', + id, + '--bundle', + bundle.path, + ]); + + if (installationResult != 0) { + logging.severe('Could not install ${bundle.path} on $id'); + return false; + } + + logging.fine('Installation successful'); + return true; + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future 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 { + return false; + } + return false; + } + + @override + TargetPlatform get platform => TargetPlatform.iOS; + + /// Note that clear is not supported on iOS at this time. + Future 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.fromId(id)); + device._name = name; + if (iOSSimulatorPath == null) { + iOSSimulatorPath = path.join( + '/Applications', 'iOS Simulator.app', 'Contents', 'MacOS', 'iOS Simulator' + ); + } + device._iOSSimPath = iOSSimulatorPath; + return device; + } + + IOSSimulator.fromId(String id) : super.fromId(id); + + static _IOSSimulatorInfo _getRunningSimulatorInfo([IOSSimulator mockIOS]) { + String xcrunPath = mockIOS != null ? mockIOS.xcrunPath : _xcrunPath; + String output = runCheckedSync([xcrunPath, 'simctl', 'list', 'devices']); + + Match match; + // iPhone 6s Plus (8AC808E1-6BAE-4153-BBC5-77F83814D414) (Booted) + Iterable matches = new RegExp( + r'[\W]*(.*) \(([^\)]+)\) \(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 match in matches) { + if (match.groupCount > 0) { + // TODO: We're killing simulator devices inside an accessor method; + // we probably shouldn't be changing state here. + logging.warning('Killing simulator ${match.group(1)}'); + runSync([xcrunPath, 'simctl', 'shutdown', match.group(2)]); + } + } + } else if (matches.length == 1) { + match = matches.first; + } + + if (match != null && match.groupCount > 0) { + return new _IOSSimulatorInfo(match.group(2), match.group(1)); + } else { + logging.info('No running simulators found'); + return null; + } + } + + String _getSimulatorPath() { + String deviceID = id == defaultDeviceID ? _getRunningSimulatorInfo()?.id : 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 getAttachedDevices([IOSSimulator mockIOS]) { + List devices = []; + _IOSSimulatorInfo deviceInfo = _getRunningSimulatorInfo(mockIOS); + if (deviceInfo != null) + devices.add(new IOSSimulator(id: deviceInfo.id, name: deviceInfo.name)); + return devices; + } + + Future boot() async { + if (!Platform.isMacOS) + return false; + if (isConnected()) + return true; + if (id == defaultDeviceID) { + runDetached([iOSSimPath]); + Future 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; + _IOSSimulatorInfo deviceInfo = _getRunningSimulatorInfo(); + if (deviceInfo == null) { + return false; + } else if (deviceInfo.id == defaultDeviceID) { + return true; + } else { + return _getRunningSimulatorInfo()?.id == id; + } + } + + @override + bool isAppInstalled(ApplicationPackage app) { + try { + String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); + return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); + } catch (e) { + return false; + } + } + + @override + Future startApp( + ApplicationPackage app, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + Map platformArgs + }) async { + // TODO: Use checked, mainPath, route + logging.fine('Building ${app.name} for $id'); + + // Step 1: Build the Xcode project + bool buildResult = await _buildIOSXcodeProject(app, false); + if (!buildResult) { + logging.severe('Could not build the application for the simulator'); + return false; + } + + // Step 2: Assert that the Xcode project was successfully built + Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app')); + bool bundleExists = await bundle.exists(); + if (!bundleExists) { + logging.severe('Could not find the built application bundle at ${bundle.path}'); + return false; + } + + // Step 3: Install the updated bundle to the simulator + int installResult = await runCommandAndStreamOutput([ + xcrunPath, + 'simctl', + 'install', + id == defaultDeviceID ? 'booted' : id, + path.absolute(bundle.path) + ]); + + if (installResult != 0) { + logging.severe('Could not install the application bundle on the simulator'); + return false; + } + + // Step 4: Launch the updated application in the simulator + int launchResult = await runCommandAndStreamOutput([ + xcrunPath, + 'simctl', + 'launch', + id == defaultDeviceID ? 'booted' : id, + app.id + ]); + + if (launchResult != 0) { + logging.severe('Could not launch the freshly installed application on the simulator'); + return false; + } + + logging.fine('Successfully started ${app.name} on $id'); + return true; + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future 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 logs({bool clear: false}) async { + if (!isConnected()) + return 2; + + String homeDirectory = path.absolute(Platform.environment['HOME']); + String simulatorDeviceID = _getRunningSimulatorInfo().id; + 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 _IOSSimulatorInfo { + final String id; + final String name; + + _IOSSimulatorInfo(this.id, this.name); +} + +Future _buildIOSXcodeProject(ApplicationPackage app, bool isDevice) async { + List command = [ + '/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release' + ]; + + if (!isDevice) { + command.addAll(['-sdk', 'iphonesimulator']); + } + + int result = await runCommandAndStreamOutput(command, + workingDirectory: app.localPath); + return result == 0; +} diff --git a/packages/flutter_tools/test/android_device_test.dart b/packages/flutter_tools/test/android_device_test.dart index 99f90006cb2..8c85b253de7 100644 --- a/packages/flutter_tools/test/android_device_test.dart +++ b/packages/flutter_tools/test/android_device_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/android/device_android.dart'; import 'package:test/test.dart'; main() => defineTests(); diff --git a/packages/flutter_tools/test/src/mocks.dart b/packages/flutter_tools/test/src/mocks.dart index d3beee67151..9af04634605 100644 --- a/packages/flutter_tools/test/src/mocks.dart +++ b/packages/flutter_tools/test/src/mocks.dart @@ -2,9 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools/src/android/device_android.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/build_configuration.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/device_ios.dart'; import 'package:flutter_tools/src/runner/flutter_command.dart'; import 'package:flutter_tools/src/toolchain.dart'; import 'package:mockito/mockito.dart';