• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2016 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:convert';
7import 'dart:io';
8import 'dart:math' as math;
9
10import 'package:meta/meta.dart';
11import 'package:path/path.dart' as path;
12
13import 'utils.dart';
14
15/// The root of the API for controlling devices.
16DeviceDiscovery get devices => DeviceDiscovery();
17
18/// Device operating system the test is configured to test.
19enum DeviceOperatingSystem { android, ios }
20
21/// Device OS to test on.
22DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android;
23
24/// Discovers available devices and chooses one to work with.
25abstract class DeviceDiscovery {
26  factory DeviceDiscovery() {
27    switch (deviceOperatingSystem) {
28      case DeviceOperatingSystem.android:
29        return AndroidDeviceDiscovery();
30      case DeviceOperatingSystem.ios:
31        return IosDeviceDiscovery();
32      default:
33        throw StateError('Unsupported device operating system: {config.deviceOperatingSystem}');
34    }
35  }
36
37  /// Selects a device to work with, load-balancing between devices if more than
38  /// one are available.
39  ///
40  /// Calling this method does not guarantee that the same device will be
41  /// returned. For such behavior see [workingDevice].
42  Future<void> chooseWorkingDevice();
43
44  /// A device to work with.
45  ///
46  /// Returns the same device when called repeatedly (unlike
47  /// [chooseWorkingDevice]). This is useful when you need to perform multiple
48  /// operations on one.
49  Future<Device> get workingDevice;
50
51  /// Lists all available devices' IDs.
52  Future<List<String>> discoverDevices();
53
54  /// Checks the health of the available devices.
55  Future<Map<String, HealthCheckResult>> checkDevices();
56
57  /// Prepares the system to run tasks.
58  Future<void> performPreflightTasks();
59}
60
61/// A proxy for one specific device.
62abstract class Device {
63  /// A unique device identifier.
64  String get deviceId;
65
66  /// Whether the device is awake.
67  Future<bool> isAwake();
68
69  /// Whether the device is asleep.
70  Future<bool> isAsleep();
71
72  /// Wake up the device if it is not awake.
73  Future<void> wakeUp();
74
75  /// Send the device to sleep mode.
76  Future<void> sendToSleep();
77
78  /// Emulates pressing the power button, toggling the device's on/off state.
79  Future<void> togglePower();
80
81  /// Unlocks the device.
82  ///
83  /// Assumes the device doesn't have a secure unlock pattern.
84  Future<void> unlock();
85
86  /// Emulate a tap on the touch screen.
87  Future<void> tap(int x, int y);
88
89  /// Read memory statistics for a process.
90  Future<Map<String, dynamic>> getMemoryStats(String packageName);
91
92  /// Stream the system log from the device.
93  ///
94  /// Flutter applications' `print` statements end up in this log
95  /// with some prefix.
96  Stream<String> get logcat;
97
98  /// Stop a process.
99  Future<void> stop(String packageName);
100}
101
102class AndroidDeviceDiscovery implements DeviceDiscovery {
103  factory AndroidDeviceDiscovery() {
104    return _instance ??= AndroidDeviceDiscovery._();
105  }
106
107  AndroidDeviceDiscovery._();
108
109  // Parses information about a device. Example:
110  //
111  // 015d172c98400a03       device usb:340787200X product:nakasi model:Nexus_7 device:grouper
112  static final RegExp _kDeviceRegex = RegExp(r'^(\S+)\s+(\S+)(.*)');
113
114  static AndroidDeviceDiscovery _instance;
115
116  AndroidDevice _workingDevice;
117
118  @override
119  Future<AndroidDevice> get workingDevice async {
120    if (_workingDevice == null) {
121      await chooseWorkingDevice();
122    }
123
124    return _workingDevice;
125  }
126
127  /// Picks a random Android device out of connected devices and sets it as
128  /// [workingDevice].
129  @override
130  Future<void> chooseWorkingDevice() async {
131    final List<Device> allDevices = (await discoverDevices())
132      .map<Device>((String id) => AndroidDevice(deviceId: id))
133      .toList();
134
135    if (allDevices.isEmpty)
136      throw 'No Android devices detected';
137
138    // TODO(yjbanov): filter out and warn about those with low battery level
139    _workingDevice = allDevices[math.Random().nextInt(allDevices.length)];
140  }
141
142  @override
143  Future<List<String>> discoverDevices() async {
144    final List<String> output = (await eval(adbPath, <String>['devices', '-l'], canFail: false))
145        .trim().split('\n');
146    final List<String> results = <String>[];
147    for (String line in output) {
148      // Skip lines like: * daemon started successfully *
149      if (line.startsWith('* daemon '))
150        continue;
151
152      if (line.startsWith('List of devices'))
153        continue;
154
155      if (_kDeviceRegex.hasMatch(line)) {
156        final Match match = _kDeviceRegex.firstMatch(line);
157
158        final String deviceID = match[1];
159        final String deviceState = match[2];
160
161        if (!const <String>['unauthorized', 'offline'].contains(deviceState)) {
162          results.add(deviceID);
163        }
164      } else {
165        throw 'Failed to parse device from adb output: "$line"';
166      }
167    }
168
169    return results;
170  }
171
172  @override
173  Future<Map<String, HealthCheckResult>> checkDevices() async {
174    final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
175    for (String deviceId in await discoverDevices()) {
176      try {
177        final AndroidDevice device = AndroidDevice(deviceId: deviceId);
178        // Just a smoke test that we can read wakefulness state
179        // TODO(yjbanov): check battery level
180        await device._getWakefulness();
181        results['android-device-$deviceId'] = HealthCheckResult.success();
182      } catch (e, s) {
183        results['android-device-$deviceId'] = HealthCheckResult.error(e, s);
184      }
185    }
186    return results;
187  }
188
189  @override
190  Future<void> performPreflightTasks() async {
191    // Kills the `adb` server causing it to start a new instance upon next
192    // command.
193    //
194    // Restarting `adb` helps with keeping device connections alive. When `adb`
195    // runs non-stop for too long it loses connections to devices. There may be
196    // a better method, but so far that's the best one I've found.
197    await exec(adbPath, <String>['kill-server'], canFail: false);
198  }
199}
200
201class AndroidDevice implements Device {
202  AndroidDevice({@required this.deviceId});
203
204  @override
205  final String deviceId;
206
207  /// Whether the device is awake.
208  @override
209  Future<bool> isAwake() async {
210    return await _getWakefulness() == 'Awake';
211  }
212
213  /// Whether the device is asleep.
214  @override
215  Future<bool> isAsleep() async {
216    return await _getWakefulness() == 'Asleep';
217  }
218
219  /// Wake up the device if it is not awake using [togglePower].
220  @override
221  Future<void> wakeUp() async {
222    if (!(await isAwake()))
223      await togglePower();
224  }
225
226  /// Send the device to sleep mode if it is not asleep using [togglePower].
227  @override
228  Future<void> sendToSleep() async {
229    if (!(await isAsleep()))
230      await togglePower();
231  }
232
233  /// Sends `KEYCODE_POWER` (26), which causes the device to toggle its mode
234  /// between awake and asleep.
235  @override
236  Future<void> togglePower() async {
237    await shellExec('input', const <String>['keyevent', '26']);
238  }
239
240  /// Unlocks the device by sending `KEYCODE_MENU` (82).
241  ///
242  /// This only works when the device doesn't have a secure unlock pattern.
243  @override
244  Future<void> unlock() async {
245    await wakeUp();
246    await shellExec('input', const <String>['keyevent', '82']);
247  }
248
249  @override
250  Future<void> tap(int x, int y) async {
251    await shellExec('input', <String>['tap', '$x', '$y']);
252  }
253
254  /// Retrieves device's wakefulness state.
255  ///
256  /// See: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/PowerManagerInternal.java
257  Future<String> _getWakefulness() async {
258    final String powerInfo = await shellEval('dumpsys', <String>['power']);
259    final String wakefulness = grep('mWakefulness=', from: powerInfo).single.split('=')[1].trim();
260    return wakefulness;
261  }
262
263  /// Executes [command] on `adb shell` and returns its exit code.
264  Future<void> shellExec(String command, List<String> arguments, { Map<String, String> environment }) async {
265    await adb(<String>['shell', command, ...arguments], environment: environment);
266  }
267
268  /// Executes [command] on `adb shell` and returns its standard output as a [String].
269  Future<String> shellEval(String command, List<String> arguments, { Map<String, String> environment }) {
270    return adb(<String>['shell', command, ...arguments], environment: environment);
271  }
272
273  /// Runs `adb` with the given [arguments], selecting this device.
274  Future<String> adb(List<String> arguments, { Map<String, String> environment }) {
275    return eval(adbPath, <String>['-s', deviceId, ...arguments], environment: environment, canFail: false);
276  }
277
278  @override
279  Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
280    final String meminfo = await shellEval('dumpsys', <String>['meminfo', packageName]);
281    final Match match = RegExp(r'TOTAL\s+(\d+)').firstMatch(meminfo);
282    assert(match != null, 'could not parse dumpsys meminfo output');
283    return <String, dynamic>{
284      'total_kb': int.parse(match.group(1)),
285    };
286  }
287
288  @override
289  Stream<String> get logcat {
290    final Completer<void> stdoutDone = Completer<void>();
291    final Completer<void> stderrDone = Completer<void>();
292    final Completer<void> processDone = Completer<void>();
293    final Completer<void> abort = Completer<void>();
294    bool aborted = false;
295    StreamController<String> stream;
296    stream = StreamController<String>(
297      onListen: () async {
298        await adb(<String>['logcat', '--clear']);
299        final Process process = await startProcess(
300          adbPath,
301          // Make logcat less chatty by filtering down to just ActivityManager
302          // (to let us know when app starts), flutter (needed by tests to see
303          // log output), and fatal messages (hopefully catches tombstones).
304          // For local testing, this can just be:
305          //   <String>['-s', deviceId, 'logcat']
306          // to view the whole log, or just run logcat alongside this.
307          <String>['-s', deviceId, 'logcat', 'ActivityManager:I', 'flutter:V', '*:F'],
308        );
309        process.stdout
310          .transform<String>(utf8.decoder)
311          .transform<String>(const LineSplitter())
312          .listen((String line) {
313            print('adb logcat: $line');
314            stream.sink.add(line);
315          }, onDone: () { stdoutDone.complete(); });
316        process.stderr
317          .transform<String>(utf8.decoder)
318          .transform<String>(const LineSplitter())
319          .listen((String line) {
320            print('adb logcat stderr: $line');
321          }, onDone: () { stderrDone.complete(); });
322        process.exitCode.then<void>((int exitCode) {
323          print('adb logcat process terminated with exit code $exitCode');
324          if (!aborted) {
325            stream.addError(BuildFailedError('adb logcat failed with exit code $exitCode.'));
326            processDone.complete();
327          }
328        });
329        await Future.any<dynamic>(<Future<dynamic>>[
330          Future.wait<void>(<Future<void>>[
331            stdoutDone.future,
332            stderrDone.future,
333            processDone.future,
334          ]),
335          abort.future,
336        ]);
337        aborted = true;
338        print('terminating adb logcat');
339        process.kill();
340        print('closing logcat stream');
341        await stream.close();
342      },
343      onCancel: () {
344        if (!aborted) {
345          print('adb logcat aborted');
346          aborted = true;
347          abort.complete();
348        }
349      },
350    );
351    return stream.stream;
352  }
353
354  @override
355  Future<void> stop(String packageName) async {
356    return shellExec('am', <String>['force-stop', packageName]);
357  }
358}
359
360class IosDeviceDiscovery implements DeviceDiscovery {
361  factory IosDeviceDiscovery() {
362    return _instance ??= IosDeviceDiscovery._();
363  }
364
365  IosDeviceDiscovery._();
366
367  static IosDeviceDiscovery _instance;
368
369  IosDevice _workingDevice;
370
371  @override
372  Future<IosDevice> get workingDevice async {
373    if (_workingDevice == null) {
374      await chooseWorkingDevice();
375    }
376
377    return _workingDevice;
378  }
379
380  /// Picks a random iOS device out of connected devices and sets it as
381  /// [workingDevice].
382  @override
383  Future<void> chooseWorkingDevice() async {
384    final List<IosDevice> allDevices = (await discoverDevices())
385      .map<IosDevice>((String id) => IosDevice(deviceId: id))
386      .toList();
387
388    if (allDevices.isEmpty)
389      throw 'No iOS devices detected';
390
391    // TODO(yjbanov): filter out and warn about those with low battery level
392    _workingDevice = allDevices[math.Random().nextInt(allDevices.length)];
393  }
394
395  // Returns the path to cached binaries relative to devicelab directory
396  String get _artifactDirPath {
397    return path.normalize(
398      path.join(
399        path.current,
400        '../../bin/cache/artifacts',
401      )
402    );
403  }
404
405  // Returns a colon-separated environment variable that contains the paths
406  // of linked libraries for idevice_id
407  Map<String, String> get _ideviceIdEnvironment {
408    final String libPath = const <String>[
409      'libimobiledevice',
410      'usbmuxd',
411      'libplist',
412      'openssl',
413      'ideviceinstaller',
414      'ios-deploy',
415    ].map((String packageName) => path.join(_artifactDirPath, packageName)).join(':');
416    return <String, String>{'DYLD_LIBRARY_PATH': libPath};
417  }
418
419  @override
420  Future<List<String>> discoverDevices() async {
421    final String ideviceIdPath = path.join(_artifactDirPath, 'libimobiledevice', 'idevice_id');
422    final List<String> iosDeviceIDs = LineSplitter.split(await eval(ideviceIdPath, <String>['-l'], environment: _ideviceIdEnvironment))
423      .map<String>((String line) => line.trim())
424      .where((String line) => line.isNotEmpty)
425      .toList();
426    if (iosDeviceIDs.isEmpty)
427      throw 'No connected iOS devices found.';
428    return iosDeviceIDs;
429  }
430
431  @override
432  Future<Map<String, HealthCheckResult>> checkDevices() async {
433    final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
434    for (String deviceId in await discoverDevices()) {
435      // TODO(ianh): do a more meaningful connectivity check than just recording the ID
436      results['ios-device-$deviceId'] = HealthCheckResult.success();
437    }
438    return results;
439  }
440
441  @override
442  Future<void> performPreflightTasks() async {
443    // Currently we do not have preflight tasks for iOS.
444  }
445}
446
447/// iOS device.
448class IosDevice implements Device {
449  const IosDevice({ @required this.deviceId });
450
451  @override
452  final String deviceId;
453
454  // The methods below are stubs for now. They will need to be expanded.
455  // We currently do not have a way to lock/unlock iOS devices. So we assume the
456  // devices are already unlocked. For now we'll just keep them at minimum
457  // screen brightness so they don't drain battery too fast.
458
459  @override
460  Future<bool> isAwake() async => true;
461
462  @override
463  Future<bool> isAsleep() async => false;
464
465  @override
466  Future<void> wakeUp() async {}
467
468  @override
469  Future<void> sendToSleep() async {}
470
471  @override
472  Future<void> togglePower() async {}
473
474  @override
475  Future<void> unlock() async {}
476
477  @override
478  Future<void> tap(int x, int y) async {
479    throw 'Not implemented';
480  }
481
482  @override
483  Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
484    throw 'Not implemented';
485  }
486
487  @override
488  Stream<String> get logcat {
489    throw 'Not implemented';
490  }
491
492  @override
493  Future<void> stop(String packageName) async {}
494}
495
496/// Path to the `adb` executable.
497String get adbPath {
498  final String androidHome = Platform.environment['ANDROID_HOME'] ?? Platform.environment['ANDROID_SDK_ROOT'];
499
500  if (androidHome == null)
501    throw 'The ANDROID_SDK_ROOT and ANDROID_HOME environment variables are '
502        'missing. At least one of these variables must point to the Android '
503        'SDK directory containing platform-tools.';
504
505  final String adbPath = path.join(androidHome, 'platform-tools/adb');
506
507  if (!canRun(adbPath))
508    throw 'adb not found at: $adbPath';
509
510  return path.absolute(adbPath);
511}
512