blob: 04bba7df3495c22b43dd1fedc22201001584706f [file] [log] [blame]
// Copyright 2016 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' show LineSplitter;
import 'dart:io';
import 'package:meta/meta.dart';
import 'utils.dart';
/// The root of the API for controlling devices.
DeviceDiscovery get devices => DeviceDiscovery();
/// Operating system on the devices that this agent is configured to test.
enum HostType { vm, physical }
/// Operating system on the devices that this agent is configured to test.
enum DeviceOperatingSystem { android, ios, none }
/// Discovers available devices and chooses one to work with.
abstract class DeviceDiscovery {
factory DeviceDiscovery() {
switch (config.deviceOperatingSystem) {
case DeviceOperatingSystem.android:
return AndroidDeviceDiscovery();
case DeviceOperatingSystem.ios:
return IosDeviceDiscovery();
case DeviceOperatingSystem.none:
return NoOpDeviceDiscovery();
default:
throw StateError(
'Unsupported device operating system: {config.deviceOperatingSystem}');
}
}
/// Lists all available devices' IDs.
Future<List<Device>> discoverDevices({int retriesDelayMs = 10000});
/// 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 {
/// 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();
/// Turns off TalkBack on Android devices, does nothing on iOS devices.
Future<void> disableAccessibility();
/// Unlocks the device.
///
/// Assumes the device doesn't have a secure unlock pattern.
Future<void> unlock();
}
/// Constant battery health values returned from Android Battery Manager.
///
/// https://developer.android.com/reference/android/os/BatteryManager.html
class AndroidBatteryHealth {
// Match SCREAMING_CAPS to Android constants.
static const BATTERY_HEALTH_UNKNOWN = 1;
static const BATTERY_HEALTH_GOOD = 2;
static const BATTERY_HEALTH_OVERHEAT = 3;
static const BATTERY_HEALTH_DEAD = 4;
static const BATTERY_HEALTH_OVER_VOLTAGE = 5;
static const BATTERY_HEALTH_UNSPECIFIED_FAILURE = 6;
static const BATTERY_HEALTH_COLD = 7;
}
class AndroidDeviceDiscovery implements DeviceDiscovery {
factory AndroidDeviceDiscovery() {
return _instance ??= AndroidDeviceDiscovery._();
}
AndroidDeviceDiscovery._();
@visibleForTesting
AndroidDeviceDiscovery.testing();
// 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;
Future<String> deviceListOutput() async {
return eval(config.adbPath, <String>['devices', '-l'], canFail: false)
.timeout(Duration(seconds: 15));
}
Future<List<String>> deviceListOutputWithRetries(int retriesDelayMs) async {
int retry = 0;
while (true) {
try {
String result = await deviceListOutput();
return result.trim().split('\n');
} on TimeoutException {
retry++;
if (retry >= 3) {
throw new TimeoutException('Can not get devices data');
}
killAdbServer();
await Future<void>.delayed(Duration(milliseconds: retriesDelayMs));
}
}
}
void killAdbServer() async {
if (Platform.isWindows) {
await killAllRunningProcessesOnWindows('adb');
} else {
await exec(config.adbPath, <String>['kill-server'], canFail: false);
}
}
@override
Future<List<Device>> discoverDevices({int retriesDelayMs = 10000}) async {
List<String> output = await deviceListOutputWithRetries(retriesDelayMs);
List<String> results = <String>[];
for (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)) {
Match match = _kDeviceRegex.firstMatch(line);
String deviceID = match[1];
String deviceState = match[2];
if (!const ['unauthorized', 'offline'].contains(deviceState)) {
results.add(deviceID);
}
} else {
throw 'Failed to parse device from adb output: $line';
}
}
return results.map((String id) => AndroidDevice(deviceId: id)).toList();
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (Device device in await discoverDevices()) {
final String deviceResultKey = 'android-device-${device.deviceId}';
if (device is AndroidDevice) {
try {
// Just a smoke test that we can read wakefulness state
await device._getWakefulness();
results[deviceResultKey] = await device.batteryHealth();
} catch (e, s) {
results[deviceResultKey] = HealthCheckResult.error(e, s);
}
}
}
return results;
}
@override
Future<void> performPreflightTasks() async {
// Checks required for the agent to start.
}
}
class AndroidDevice implements Device {
AndroidDevice({@required this.deviceId});
@override
final String deviceId;
/// 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 ['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 ['keyevent', '82']);
}
@override
Future<void> disableAccessibility() async {
await shellExec('settings',
['put', 'secure', 'enabled_accessibility_services', 'null']);
}
/// Retrieves device's wakefulness state.
///
/// See: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/PowerManagerInternal.java
Future<String> _getWakefulness() async {
String powerInfo = await shellEval('dumpsys', ['power']);
String wakefulness =
grep('mWakefulness=', from: powerInfo).single.split('=')[1].trim();
return wakefulness;
}
/// Retrieves battery health reported from dumpsys battery.
Future<HealthCheckResult> batteryHealth() async {
try {
String batteryInfo = await shellEval('dumpsys', ['battery']);
String batteryTemperatureString =
grep('health: ', from: batteryInfo).single.split(': ')[1].trim();
int batteryHeath = int.parse(batteryTemperatureString);
switch (batteryHeath) {
case AndroidBatteryHealth.BATTERY_HEALTH_OVERHEAT:
return HealthCheckResult.failure('Battery overheated');
case AndroidBatteryHealth.BATTERY_HEALTH_DEAD:
return HealthCheckResult.failure('Battery dead');
case AndroidBatteryHealth.BATTERY_HEALTH_OVER_VOLTAGE:
return HealthCheckResult.failure('Battery over voltage');
case AndroidBatteryHealth.BATTERY_HEALTH_UNSPECIFIED_FAILURE:
return HealthCheckResult.failure('Unspecified battery failure');
case AndroidBatteryHealth.BATTERY_HEALTH_COLD:
return HealthCheckResult.failure('Battery cold');
case AndroidBatteryHealth.BATTERY_HEALTH_UNKNOWN:
return HealthCheckResult.success('Battery health unknown');
case AndroidBatteryHealth.BATTERY_HEALTH_GOOD:
return HealthCheckResult.success();
default:
// Unknown code.
return HealthCheckResult.success(
'Unknown battery health value $batteryHeath');
}
} catch (e) {
// dumpsys battery not supported.
return HealthCheckResult.success('Unknown battery health');
}
}
/// Executes [command] on `adb shell` and returns its exit code.
Future<void> shellExec(String command, List<String> arguments,
{Map<String, String> env}) async {
await exec(config.adbPath, ['shell', command]..addAll(arguments),
env: env, canFail: false);
}
/// Executes [command] on `adb shell` and returns its standard output as a [String].
Future<String> shellEval(String command, List<String> arguments,
{Map<String, String> env}) {
return eval(config.adbPath, ['shell', command]..addAll(arguments),
env: env, canFail: false);
}
}
class IosDeviceDiscovery implements DeviceDiscovery {
factory IosDeviceDiscovery() {
return _instance ??= IosDeviceDiscovery._();
}
IosDeviceDiscovery._();
static IosDeviceDiscovery _instance;
@override
Future<List<Device>> discoverDevices({int retriesDelayMs = 10000}) async {
List<String> iosDeviceIds =
LineSplitter.split(await eval('idevice_id', ['-l'])).toList();
if (iosDeviceIds.isEmpty) throw 'No connected iOS devices found.';
return iosDeviceIds.map((String id) => IosDevice(deviceId: id)).toList();
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (Device device in await discoverDevices()) {
// TODO: do a more meaningful connectivity check than just recording the ID
results['ios-device-${device.deviceId}'] = HealthCheckResult.success();
}
return results;
}
@override
Future<void> performPreflightTasks() async {
// Currently we do not have preflight tasks for iOS.
return null;
}
}
class NoOpDeviceDiscovery implements DeviceDiscovery {
factory NoOpDeviceDiscovery() {
return _instance ??= NoOpDeviceDiscovery._();
}
NoOpDeviceDiscovery._();
static NoOpDeviceDiscovery _instance;
@override
Future<List<Device>> discoverDevices({int retriesDelayMs = 10000}) async {
return [];
}
@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
results['no-device'] = HealthCheckResult.success();
return results;
}
@override
Future<void> performPreflightTasks() async {
// Currently we do not have preflight tasks for hosts without attached devices.
return null;
}
}
/// iOS device.
class IosDevice implements 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> disableAccessibility() async {}
}