blob: 9d2ed7aac597e1cc1b8324c7047bd2c731323976 [file] [log] [blame]
// Copyright 2014 The Flutter 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 'dart:math' as math;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'utils.dart';
const String DeviceIdEnvName = 'FLUTTER_DEVICELAB_DEVICEID';
class DeviceException implements Exception {
const DeviceException(this.message);
final String message;
@override
String toString() => message == null ? '$DeviceException' : '$DeviceException: $message';
}
/// Gets the artifact path relative to the current directory.
String getArtifactPath() {
return path.normalize(
path.join(
path.current,
'../../bin/cache/artifacts',
)
);
}
/// Return the item is in idList if find a match, otherwise return null
String _findMatchId(List<String> idList, String idPattern) {
String candidate;
idPattern = idPattern.toLowerCase();
for(final String id in idList) {
if (id.toLowerCase() == idPattern) {
return id;
}
if (id.toLowerCase().startsWith(idPattern)) {
candidate ??= id;
}
}
return candidate;
}
/// The root of the API for controlling devices.
DeviceDiscovery get devices => DeviceDiscovery();
/// Device operating system the test is configured to test.
enum DeviceOperatingSystem { android, ios, fuchsia, fake }
/// Device OS to test on.
DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android;
/// Discovers available devices and chooses one to work with.
abstract class DeviceDiscovery {
factory DeviceDiscovery() {
switch (deviceOperatingSystem) {
case DeviceOperatingSystem.android:
return AndroidDeviceDiscovery();
case DeviceOperatingSystem.ios:
return IosDeviceDiscovery();
case DeviceOperatingSystem.fuchsia:
return FuchsiaDeviceDiscovery();
case DeviceOperatingSystem.fake:
print('Looking for fake devices!'
'You should not see this in release builds.');
return FakeDeviceDiscovery();
default:
throw DeviceException('Unsupported device operating system: $deviceOperatingSystem');
}
}
/// Selects a device to work with, load-balancing between devices if more than
/// one are available.
///
/// Calling this method does not guarantee that the same device will be
/// returned. For such behavior see [workingDevice].
Future<void> chooseWorkingDevice();
/// Selects a device to work with by device ID.
Future<void> chooseWorkingDeviceById(String deviceId);
/// A device to work with.
///
/// Returns the same device when called repeatedly (unlike
/// [chooseWorkingDevice]). This is useful when you need to perform multiple
/// operations on one.
Future<Device> get workingDevice;
/// Lists all available devices' IDs.
Future<List<String>> discoverDevices();
/// Checks the health of the available devices.
Future<Map<String, HealthCheckResult>> checkDevices();
/// Prepares the system to run tasks.
Future<void> performPreflightTasks();
}
/// A proxy for one specific device.
abstract class Device {
// Const constructor so subclasses may be const.
const Device();
/// A unique device identifier.
String get deviceId;
/// Whether the device is awake.
Future<bool> isAwake();
/// Whether the device is asleep.
Future<bool> isAsleep();
/// Wake up the device if it is not awake.
Future<void> wakeUp();
/// Send the device to sleep mode.
Future<void> sendToSleep();
/// Emulates pressing the power button, toggling the device's on/off state.
Future<void> togglePower();
/// Unlocks the device.
///
/// Assumes the device doesn't have a secure unlock pattern.
Future<void> unlock();
/// Attempt to reboot the phone, if possible.
Future<void> reboot();
/// Emulate a tap on the touch screen.
Future<void> tap(int x, int y);
/// Read memory statistics for a process.
Future<Map<String, dynamic>> getMemoryStats(String packageName);
/// Stream the system log from the device.
///
/// Flutter applications' `print` statements end up in this log
/// with some prefix.
Stream<String> get logcat;
/// Stop a process.
Future<void> stop(String packageName);
@override
String toString() {
return 'device: $deviceId';
}
}
class AndroidDeviceDiscovery implements DeviceDiscovery {
factory AndroidDeviceDiscovery() {
return _instance ??= AndroidDeviceDiscovery._();
}
AndroidDeviceDiscovery._();
// Parses information about a device. Example:
//
// 015d172c98400a03 device usb:340787200X product:nakasi model:Nexus_7 device:grouper
static final RegExp _kDeviceRegex = RegExp(r'^(\S+)\s+(\S+)(.*)');
static AndroidDeviceDiscovery _instance;
AndroidDevice _workingDevice;
@override
Future<AndroidDevice> get workingDevice async {
if (_workingDevice == null) {
if (Platform.environment.containsKey(DeviceIdEnvName)) {
final String deviceId = Platform.environment[DeviceIdEnvName];
await chooseWorkingDeviceById(deviceId);
return _workingDevice;
}
await chooseWorkingDevice();
}
return _workingDevice;
}
/// Picks a random Android device out of connected devices and sets it as
/// [workingDevice].
@override
Future<void> chooseWorkingDevice() async {
final List<AndroidDevice> allDevices = (await discoverDevices())
.map<AndroidDevice>((String id) => AndroidDevice(deviceId: id))
.toList();
if (allDevices.isEmpty)
throw const DeviceException('No Android devices detected');
// TODO(yjbanov): filter out and warn about those with low battery level
_workingDevice = allDevices[math.Random().nextInt(allDevices.length)];
print('Device chosen: $_workingDevice');
}
@override
Future<void> chooseWorkingDeviceById(String deviceId) async {
final String matchedId = _findMatchId(await discoverDevices(), deviceId);
if (matchedId != null) {
_workingDevice = AndroidDevice(deviceId: matchedId);
print('Choose device by ID: $matchedId');
return;
}
throw DeviceException(
'Device with ID $deviceId is not found for operating system: '
'$deviceOperatingSystem'
);
}
@override
Future<List<String>> discoverDevices() async {
final List<String> output = (await eval(adbPath, <String>['devices', '-l'], canFail: false))
.trim().split('\n');
final List<String> results = <String>[];
for (final String line in output) {
// Skip lines like: * daemon started successfully *
if (line.startsWith('* daemon '))
continue;
if (line.startsWith('List of devices'))
continue;
if (_kDeviceRegex.hasMatch(line)) {
final Match match = _kDeviceRegex.firstMatch(line);
final String deviceID = match[1];
final String deviceState = match[2];
if (!const <String>['unauthorized', 'offline'].contains(deviceState)) {
results.add(deviceID);
}
} else {
throw FormatException('Failed to parse device from adb output: "$line"');
}
}
return results;
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (final String deviceId in await discoverDevices()) {
try {
final AndroidDevice device = AndroidDevice(deviceId: deviceId);
// Just a smoke test that we can read wakefulness state
// TODO(yjbanov): check battery level
await device._getWakefulness();
results['android-device-$deviceId'] = HealthCheckResult.success();
} on Exception catch (e, s) {
results['android-device-$deviceId'] = HealthCheckResult.error(e, s);
}
}
return results;
}
@override
Future<void> performPreflightTasks() async {
// Kills the `adb` server causing it to start a new instance upon next
// command.
//
// Restarting `adb` helps with keeping device connections alive. When `adb`
// runs non-stop for too long it loses connections to devices. There may be
// a better method, but so far that's the best one I've found.
await exec(adbPath, <String>['kill-server'], canFail: false);
}
}
class FuchsiaDeviceDiscovery implements DeviceDiscovery {
factory FuchsiaDeviceDiscovery() {
return _instance ??= FuchsiaDeviceDiscovery._();
}
FuchsiaDeviceDiscovery._();
static FuchsiaDeviceDiscovery _instance;
FuchsiaDevice _workingDevice;
String get _devFinder {
final String devFinder = path.join(getArtifactPath(), 'fuchsia', 'tools', 'device-finder');
if (!File(devFinder).existsSync()) {
throw FileSystemException("Couldn't find device-finder at location $devFinder");
}
return devFinder;
}
@override
Future<FuchsiaDevice> get workingDevice async {
if (_workingDevice == null) {
if (Platform.environment.containsKey(DeviceIdEnvName)) {
final String deviceId = Platform.environment[DeviceIdEnvName];
await chooseWorkingDeviceById(deviceId);
return _workingDevice;
}
await chooseWorkingDevice();
}
return _workingDevice;
}
/// Picks the first connected Fuchsia device.
@override
Future<void> chooseWorkingDevice() async {
final List<FuchsiaDevice> allDevices = (await discoverDevices())
.map<FuchsiaDevice>((String id) => FuchsiaDevice(deviceId: id))
.toList();
if (allDevices.isEmpty) {
throw const DeviceException('No Fuchsia devices detected');
}
_workingDevice = allDevices.first;
print('Device chosen: $_workingDevice');
}
@override
Future<void> chooseWorkingDeviceById(String deviceId) async {
final String matchedId = _findMatchId(await discoverDevices(), deviceId);
if (deviceId != null) {
_workingDevice = FuchsiaDevice(deviceId: matchedId);
print('Choose device by ID: $matchedId');
return;
}
throw DeviceException(
'Device with ID $deviceId is not found for operating system: '
'$deviceOperatingSystem'
);
}
@override
Future<List<String>> discoverDevices() async {
final List<String> output = (await eval(_devFinder, <String>['list', '-full']))
.trim()
.split('\n');
final List<String> devices = <String>[];
for (final String line in output) {
final List<String> parts = line.split(' ');
assert(parts.length == 2);
devices.add(parts.last); // The device id.
}
return devices;
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (final String deviceId in await discoverDevices()) {
try {
final int resolveResult = await exec(
_devFinder,
<String>[
'resolve',
'-device-limit',
'1',
deviceId,
]
);
if (resolveResult == 0) {
results['fuchsia-device-$deviceId'] = HealthCheckResult.success();
} else {
results['fuchsia-device-$deviceId'] = HealthCheckResult.failure('Cannot resolve device $deviceId');
}
} on Exception catch (error, stacktrace) {
results['fuchsia-device-$deviceId'] = HealthCheckResult.error(error, stacktrace);
}
}
return results;
}
@override
Future<void> performPreflightTasks() async {}
}
class AndroidDevice extends Device {
AndroidDevice({@required this.deviceId}) {
_updateDeviceInfo();
}
@override
final String deviceId;
String deviceInfo = '';
/// Whether the device is awake.
@override
Future<bool> isAwake() async {
return await _getWakefulness() == 'Awake';
}
/// Whether the device is asleep.
@override
Future<bool> isAsleep() async {
return await _getWakefulness() == 'Asleep';
}
/// Wake up the device if it is not awake using [togglePower].
@override
Future<void> wakeUp() async {
if (!(await isAwake()))
await togglePower();
}
/// Send the device to sleep mode if it is not asleep using [togglePower].
@override
Future<void> sendToSleep() async {
if (!(await isAsleep()))
await togglePower();
}
/// Sends `KEYCODE_POWER` (26), which causes the device to toggle its mode
/// between awake and asleep.
@override
Future<void> togglePower() async {
await shellExec('input', const <String>['keyevent', '26']);
}
/// Unlocks the device by sending `KEYCODE_MENU` (82).
///
/// This only works when the device doesn't have a secure unlock pattern.
@override
Future<void> unlock() async {
await wakeUp();
await shellExec('input', const <String>['keyevent', '82']);
}
@override
Future<void> tap(int x, int y) async {
await shellExec('input', <String>['tap', '$x', '$y']);
}
/// Retrieves device's wakefulness state.
///
/// See: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/PowerManagerInternal.java
Future<String> _getWakefulness() async {
final String powerInfo = await shellEval('dumpsys', <String>['power']);
final String wakefulness = grep('mWakefulness=', from: powerInfo).single.split('=')[1].trim();
return wakefulness;
}
Future<void> _updateDeviceInfo() async {
String info;
try {
info = await shellEval(
'getprop',
<String>[
'ro.bootimage.build.fingerprint', ';',
'getprop', 'ro.build.version.release', ';',
'getprop', 'ro.build.version.sdk',
],
silent: true,
);
} on IOException {
info = '';
}
final List<String> list = info.split('\n');
if (list.length == 3) {
deviceInfo = 'fingerprint: ${list[0]} os: ${list[1]} api-level: ${list[2]}';
} else {
deviceInfo = '';
}
}
/// Executes [command] on `adb shell` and returns its exit code.
Future<void> shellExec(String command, List<String> arguments, { Map<String, String> environment, bool silent = false }) async {
await adb(<String>['shell', command, ...arguments], environment: environment, silent: silent);
}
/// Executes [command] on `adb shell` and returns its standard output as a [String].
Future<String> shellEval(String command, List<String> arguments, { Map<String, String> environment, bool silent = false }) {
return adb(<String>['shell', command, ...arguments], environment: environment, silent: silent);
}
/// Runs `adb` with the given [arguments], selecting this device.
Future<String> adb(
List<String> arguments, {
Map<String, String> environment,
bool silent = false,
}) {
return eval(
adbPath,
<String>['-s', deviceId, ...arguments],
environment: environment,
canFail: false,
printStdout: !silent,
printStderr: !silent,
);
}
@override
Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
final String meminfo = await shellEval('dumpsys', <String>['meminfo', packageName]);
final Match match = RegExp(r'TOTAL\s+(\d+)').firstMatch(meminfo);
assert(match != null, 'could not parse dumpsys meminfo output');
return <String, dynamic>{
'total_kb': int.parse(match.group(1)),
};
}
@override
Stream<String> get logcat {
final Completer<void> stdoutDone = Completer<void>();
final Completer<void> stderrDone = Completer<void>();
final Completer<void> processDone = Completer<void>();
final Completer<void> abort = Completer<void>();
bool aborted = false;
StreamController<String> stream;
stream = StreamController<String>(
onListen: () async {
await adb(<String>['logcat', '--clear']);
final Process process = await startProcess(
adbPath,
// Make logcat less chatty by filtering down to just ActivityManager
// (to let us know when app starts), flutter (needed by tests to see
// log output), and fatal messages (hopefully catches tombstones).
// For local testing, this can just be:
// <String>['-s', deviceId, 'logcat']
// to view the whole log, or just run logcat alongside this.
<String>['-s', deviceId, 'logcat', 'ActivityManager:I', 'flutter:V', '*:F'],
);
process.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
print('adb logcat: $line');
stream.sink.add(line);
}, onDone: () { stdoutDone.complete(); });
process.stderr
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
print('adb logcat stderr: $line');
}, onDone: () { stderrDone.complete(); });
process.exitCode.then<void>((int exitCode) {
print('adb logcat process terminated with exit code $exitCode');
if (!aborted) {
stream.addError(BuildFailedError('adb logcat failed with exit code $exitCode.\n'));
processDone.complete();
}
});
await Future.any<dynamic>(<Future<dynamic>>[
Future.wait<void>(<Future<void>>[
stdoutDone.future,
stderrDone.future,
processDone.future,
]),
abort.future,
]);
aborted = true;
print('terminating adb logcat');
process.kill();
print('closing logcat stream');
await stream.close();
},
onCancel: () {
if (!aborted) {
print('adb logcat aborted');
aborted = true;
abort.complete();
}
},
);
return stream.stream;
}
@override
Future<void> stop(String packageName) async {
return shellExec('am', <String>['force-stop', packageName]);
}
@override
String toString() {
return '$deviceId $deviceInfo';
}
@override
Future<void> reboot() {
return adb(<String>['reboot']);
}
}
class IosDeviceDiscovery implements DeviceDiscovery {
factory IosDeviceDiscovery() {
return _instance ??= IosDeviceDiscovery._();
}
IosDeviceDiscovery._();
static IosDeviceDiscovery _instance;
IosDevice _workingDevice;
@override
Future<IosDevice> get workingDevice async {
if (_workingDevice == null) {
if (Platform.environment.containsKey(DeviceIdEnvName)) {
final String deviceId = Platform.environment[DeviceIdEnvName];
await chooseWorkingDeviceById(deviceId);
return _workingDevice;
}
await chooseWorkingDevice();
}
return _workingDevice;
}
/// Picks a random iOS device out of connected devices and sets it as
/// [workingDevice].
@override
Future<void> chooseWorkingDevice() async {
final List<IosDevice> allDevices = (await discoverDevices())
.map<IosDevice>((String id) => IosDevice(deviceId: id))
.toList();
if (allDevices.isEmpty)
throw const DeviceException('No iOS devices detected');
// TODO(yjbanov): filter out and warn about those with low battery level
_workingDevice = allDevices[math.Random().nextInt(allDevices.length)];
print('Device chosen: $_workingDevice');
}
@override
Future<void> chooseWorkingDeviceById(String deviceId) async {
final String matchedId = _findMatchId(await discoverDevices(), deviceId);
if (matchedId != null) {
_workingDevice = IosDevice(deviceId: matchedId);
print('Choose device by ID: $matchedId');
return;
}
throw DeviceException(
'Device with ID $deviceId is not found for operating system: '
'$deviceOperatingSystem'
);
}
@override
Future<List<String>> discoverDevices() async {
final List<dynamic> results = json.decode(await eval(
path.join(flutterDirectory.path, 'bin', 'flutter'),
<String>['devices', '--machine', '--suppress-analytics', '--device-timeout', '5'],
)) as List<dynamic>;
// [
// {
// "name": "Flutter's iPhone",
// "id": "00008020-00017DA80CC1002E",
// "isSupported": true,
// "targetPlatform": "ios",
// "emulator": false,
// "sdk": "iOS 13.2",
// "capabilities": {
// "hotReload": true,
// "hotRestart": true,
// "screenshot": true,
// "fastStart": false,
// "flutterExit": true,
// "hardwareRendering": false,
// "startPaused": false
// }
// }
// ]
final List<String> deviceIds = <String>[];
for (final dynamic result in results) {
final Map<String, dynamic> device = result as Map<String, dynamic>;
if (device['targetPlatform'] == 'ios' &&
device['id'] != null &&
device['emulator'] != true &&
device['isSupported'] == true) {
deviceIds.add(device['id'] as String);
}
}
if (deviceIds.isEmpty) {
throw const DeviceException('No connected iOS devices found.');
}
return deviceIds;
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (final String deviceId in await discoverDevices()) {
// TODO(ianh): do a more meaningful connectivity check than just recording the ID
results['ios-device-$deviceId'] = HealthCheckResult.success();
}
return results;
}
@override
Future<void> performPreflightTasks() async {
// Currently we do not have preflight tasks for iOS.
}
}
/// iOS device.
class IosDevice extends Device {
const IosDevice({ @required this.deviceId });
@override
final String deviceId;
// The methods below are stubs for now. They will need to be expanded.
// We currently do not have a way to lock/unlock iOS devices. So we assume the
// devices are already unlocked. For now we'll just keep them at minimum
// screen brightness so they don't drain battery too fast.
@override
Future<bool> isAwake() async => true;
@override
Future<bool> isAsleep() async => false;
@override
Future<void> wakeUp() async {}
@override
Future<void> sendToSleep() async {}
@override
Future<void> togglePower() async {}
@override
Future<void> unlock() async {}
@override
Future<void> tap(int x, int y) async {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
throw UnimplementedError();
}
@override
Stream<String> get logcat {
throw UnimplementedError();
}
@override
Future<void> stop(String packageName) async {}
@override
Future<void> reboot() {
return Process.run('idevicediagnostics', <String>['restart', '-u', deviceId]);
}
}
/// Fuchsia device.
class FuchsiaDevice extends Device {
const FuchsiaDevice({ @required this.deviceId });
@override
final String deviceId;
// TODO(egarciad): Implement these for Fuchsia.
@override
Future<bool> isAwake() async => true;
@override
Future<bool> isAsleep() async => false;
@override
Future<void> wakeUp() async {}
@override
Future<void> sendToSleep() async {}
@override
Future<void> togglePower() async {}
@override
Future<void> unlock() async {}
@override
Future<void> tap(int x, int y) async {}
@override
Future<void> stop(String packageName) async {}
@override
Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
throw UnimplementedError();
}
@override
Stream<String> get logcat {
throw UnimplementedError();
}
@override
Future<void> reboot() async {
// Unsupported.
}
}
/// Path to the `adb` executable.
String get adbPath {
final String androidHome = Platform.environment['ANDROID_HOME'] ?? Platform.environment['ANDROID_SDK_ROOT'];
if (androidHome == null) {
throw const DeviceException(
'The ANDROID_SDK_ROOT environment variable is '
'missing. The variable must point to the Android '
'SDK directory containing platform-tools.'
);
}
final String adbPath = path.join(androidHome, 'platform-tools/adb');
if (!canRun(adbPath))
throw DeviceException('adb not found at: $adbPath');
return path.absolute(adbPath);
}
class FakeDevice extends Device {
const FakeDevice({ @required this.deviceId });
@override
final String deviceId;
@override
Future<bool> isAwake() async => true;
@override
Future<bool> isAsleep() async => false;
@override
Future<void> wakeUp() async {}
@override
Future<void> sendToSleep() async {}
@override
Future<void> togglePower() async {}
@override
Future<void> unlock() async {}
@override
Future<void> tap(int x, int y) async {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
throw UnimplementedError();
}
@override
Stream<String> get logcat {
throw UnimplementedError();
}
@override
Future<void> stop(String packageName) async {}
@override
Future<void> reboot() async {
// Unsupported.
}
}
class FakeDeviceDiscovery implements DeviceDiscovery {
factory FakeDeviceDiscovery() {
return _instance ??= FakeDeviceDiscovery._();
}
FakeDeviceDiscovery._();
static FakeDeviceDiscovery _instance;
FakeDevice _workingDevice;
@override
Future<FakeDevice> get workingDevice async {
if (_workingDevice == null) {
if (Platform.environment.containsKey(DeviceIdEnvName)) {
final String deviceId = Platform.environment[DeviceIdEnvName];
await chooseWorkingDeviceById(deviceId);
return _workingDevice;
}
await chooseWorkingDevice();
}
return _workingDevice;
}
/// The Fake is only available for by ID device discovery.
@override
Future<void> chooseWorkingDevice() async {
throw const DeviceException('No fake devices detected');
}
@override
Future<void> chooseWorkingDeviceById(String deviceId) async {
final String matchedId = _findMatchId(await discoverDevices(), deviceId);
if (matchedId != null) {
_workingDevice = FakeDevice(deviceId: matchedId);
print('Choose device by ID: $matchedId');
return;
}
throw DeviceException(
'Device with ID $deviceId is not found for operating system: '
'$deviceOperatingSystem'
);
}
@override
Future<List<String>> discoverDevices() async {
return <String>['FAKE_SUCCESS', 'THIS_IS_A_FAKE'];
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (final String deviceId in await discoverDevices()) {
results['fake-device-$deviceId'] = HealthCheckResult.success();
}
return results;
}
@override
Future<void> performPreflightTasks() async {
}
}