Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions agent/config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,5 @@ auth_token: blahblahblah
# get a Firebase token.
firebase_flutter_dashboard_token: blahblahblah

# Whether the device tests on Android (true by default)
tests_android: true

# Whether the device tests on iOS (false by default)
tests_ios: false
# The device operating system. Possible values: android, ios.
device_os: android
286 changes: 223 additions & 63 deletions agent/lib/src/adb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,95 +5,126 @@
import 'dart:async';
import 'dart:math' as math;

import 'package:meta/meta.dart';

import 'utils.dart';

typedef Future<Adb> AdbGetter();
/// The root of the API for controlling devices.
DeviceDiscovery get devices => new DeviceDiscovery();

/// Operating system on the devices that this agent is configured to test.
enum DeviceOperatingSystem { android, ios }

/// Discovers available devices and chooses one to work with.
abstract class DeviceDiscovery {
factory DeviceDiscovery() {
switch(config.deviceOperatingSystem) {
case DeviceOperatingSystem.android:
return new AndroidDeviceDiscovery();
case DeviceOperatingSystem.ios:
return new IosDeviceDiscovery();
default:
throw new StateError('Unsupported device operating system: {config.deviceOperatingSystem}');
}
}

/// Get an instance of [Adb].
///
/// See [realAdbGetter] for signature. This can be overwritten for testing.
AdbGetter adb = realAdbGetter;
/// Selects a device to work with, load-balancing between devices if more than
/// one are available.
///
/// A task performing multiple actions on a device would call this method
/// first then follow up by calling [workingDevice]. Calling [workingDevice]
/// repeatedly must guarantee to return the same device.
Future<Null> chooseWorkingDevice();

Adb _currentDevice;
/// Returns current working device.
///
/// Must be called _after_ a successful call to [chooseWorkingDevice]. May be
/// called repeatedly to perform multiple operations on one specific device;
/// guarantees to return the same device until the next time
/// [chooseWorkingDevice] is called.
Device get workingDevice;

/// Picks a random Android device out of connected devices and sets it as
/// [_currentDevice].
Future<Null> pickNextDevice() async {
List<Adb> allDevices = (await Adb.deviceIds)
.map((String id) => new Adb(deviceId: id))
.toList();
/// Lists all available devices' IDs.
Future<List<String>> discoverDevices();

if (allDevices.length == 0)
throw 'No Android devices detected';
/// Checks the health of the available devices.
Future<Map<String, HealthCheckResult>> checkDevices();

// TODO(yjbanov): filter out and warn about those with low battery level
_currentDevice = allDevices[new math.Random().nextInt(allDevices.length)];
/// Prepares the system to run tasks.
Future<Null> performPreflightTasks();
}

Future<Adb> realAdbGetter() async {
if (_currentDevice == null)
await pickNextDevice();
return _currentDevice;
}
/// A proxy for one specific device.
abstract class Device {
/// A unique device identifier.
String get deviceId;

/// Gets the ID of an unlocked device, unlocking it if necessary.
// TODO(yjbanov): abstract away iOS from Android.
Future<String> getUnlockedDeviceId({ bool ios: false }) async {
if (ios) {
// We currently do not have a way to lock/unlock iOS devices, or even to
// pick one out of many. So we pick the first random iPhone and assume it's
// already unlocked. For now we'll just keep them at minimum screen
// brightness so they don't drain battery too fast.
List<String> iosDeviceIds = grep('UniqueDeviceID', from: await eval('ideviceinfo', []))
.map((String line) => line.split(' ').last).toList();
/// Whether the device is awake.
Future<bool> isAwake();

if (iosDeviceIds.isEmpty)
throw 'No connected iOS devices found.';
/// Whether the device is asleep.
Future<bool> isAsleep();

return iosDeviceIds.first;
}
/// Wake up the device if it is not awake.
Future<Null> wakeUp();

Adb device = await adb();
device.unlock();
return device.deviceId;
}
/// Send the device to sleep mode.
Future<Null> sendToSleep();

class Adb {
Adb({String this.deviceId});
/// Emulates pressing the power button, toggling the device's on/off state.
Future<Null> togglePower();

final String deviceId;
/// Unlocks the device.
///
/// Assumes the device doesn't have a secure unlock pattern.
Future<Null> unlock();
}

class AndroidDeviceDiscovery implements DeviceDiscovery {
// Parses information about a device. Example:
//
// 015d172c98400a03 device usb:340787200X product:nakasi model:Nexus_7 device:grouper
static final RegExp _kDeviceRegex = new RegExp(r'^(\S+)\s+(\S+)(.*)');

static Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (String deviceId in await deviceIds) {
try {
Adb device = new Adb(deviceId: deviceId);
// Just a smoke test that we can read wakefulness state
// TODO(yjbanov): check battery level
await device._getWakefulness();
results['android-device-$deviceId'] = new HealthCheckResult.success();
} catch(e, s) {
results['android-device-$deviceId'] = new HealthCheckResult.error(e, s);
}
static AndroidDeviceDiscovery _instance;

factory AndroidDeviceDiscovery() {
return _instance ??= new AndroidDeviceDiscovery._();
}

AndroidDeviceDiscovery._();

AndroidDevice _workingDevice;

@override
AndroidDevice get workingDevice {
if (_workingDevice == null) {
throw new StateError(
'No working device chosen. Call `chooseWorkingDevice` prior to calling '
'the `workingDevice` getter.',
);
}
return results;

return _workingDevice;
}

/// 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.
static Future restart() async {
await exec(config.adbPath, ['kill-server'], canFail: false);
/// Picks a random Android device out of connected devices and sets it as
/// [workingDevice].
@override
Future<Null> chooseWorkingDevice() async {
List<Device> allDevices = (await discoverDevices())
.map((String id) => new AndroidDevice(deviceId: id))
.toList();

if (allDevices.isEmpty)
throw 'No Android devices detected';

// TODO(yjbanov): filter out and warn about those with low battery level
_workingDevice = allDevices[new math.Random().nextInt(allDevices.length)];
}

static Future<List<String>> get deviceIds async {
@override
Future<List<String>> discoverDevices() async {
List<String> output = (await eval(config.adbPath, ['devices', '-l'], canFail: false))
.trim().split('\n');
List<String> results = <String>[];
Expand Down Expand Up @@ -122,37 +153,78 @@ class Adb {
return results;
}

@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (String deviceId in await discoverDevices()) {
try {
AndroidDevice device = new 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'] = new HealthCheckResult.success();
} catch(e, s) {
results['android-device-$deviceId'] = new HealthCheckResult.error(e, s);
}
}
return results;
}

@override
Future<Null> 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(config.adbPath, <String>['kill-server'], canFail: false);
}
}

class AndroidDevice implements Device {
AndroidDevice({@required String 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<Null> wakeUp() async {
if (!(await isAwake()))
await togglePower();
}

/// Send the device to sleep mode if it is not asleep using [togglePower].
@override
Future<Null> 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<Null> 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<Null> unlock() async {
await wakeUp();
await shellExec('input', const ['keyevent', '82']);
Expand All @@ -177,3 +249,91 @@ class Adb {
return eval(config.adbPath, ['shell', command]..addAll(arguments), env: env, canFail: false);
}
}

class IosDeviceDiscovery implements DeviceDiscovery {

static IosDeviceDiscovery _instance;

factory IosDeviceDiscovery() {
return _instance ??= new IosDeviceDiscovery._();
}

IosDeviceDiscovery._();

IosDevice _workingDevice;

@override
IosDevice get workingDevice {
if (_workingDevice == null) {
throw new StateError(
'No working device chosen. Call `chooseWorkingDevice` prior to calling '
'the `workingDevice` getter.',
);
}

return _workingDevice;
}

/// Picks a random iOS device out of connected devices and sets it as
/// [workingDevice].
@override
Future<Null> chooseWorkingDevice() async {
List<IosDevice> allDevices = (await discoverDevices())
.map((String id) => new IosDevice(deviceId: id))
.toList();

if (allDevices.length == 0)
throw 'No iOS devices detected';

// TODO(yjbanov): filter out and warn about those with low battery level
_workingDevice = allDevices[new math.Random().nextInt(allDevices.length)];
}

@override
Future<List<String>> discoverDevices() async {
// TODO: use the -k UniqueDeviceID option, which requires much less parsing.
List<String> iosDeviceIds = grep('UniqueDeviceID', from: await eval('ideviceinfo', []))
.map((String line) => line.split(' ').last).toList();

if (iosDeviceIds.isEmpty)
throw 'No connected iOS devices found.';

return iosDeviceIds;
}

@override
Future<Map<String, HealthCheckResult>> checkDevices() async {
Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
for (String deviceId in await discoverDevices()) {
// TODO: do a more meaningful connectivity check than just recording the ID
results['ios-device-$deviceId'] = new HealthCheckResult.success();
}
return results;
}

@override
Future<Null> performPreflightTasks() async {
// Currently we do not have preflight tasks for iOS.
return null;
}
}

/// iOS device.
class IosDevice implements Device {
const IosDevice({ @required String 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.

Future<bool> isAwake() async => true;
Future<bool> isAsleep() async => false;
Future<Null> wakeUp() async {}
Future<Null> sendToSleep() async {}
Future<Null> togglePower() async {}
Future<Null> unlock() async {}
}
2 changes: 1 addition & 1 deletion agent/lib/src/agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class Agent {

Future<Null> _screenOff() async {
try {
await (await adb()).sendToSleep();
await devices.workingDevice.sendToSleep();
} catch(error, stackTrace) {
print('Failed to turn off screen: $error\n$stackTrace');
}
Expand Down
Loading