adb.dart 15.7 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
6
import 'dart:convert';
7 8 9
import 'dart:io';
import 'dart:math' as math;

10
import 'package:meta/meta.dart';
11 12 13 14
import 'package:path/path.dart' as path;

import 'utils.dart';

15
/// The root of the API for controlling devices.
16
DeviceDiscovery get devices => DeviceDiscovery();
17 18 19 20 21 22 23 24 25 26

/// Device operating system the test is configured to test.
enum DeviceOperatingSystem { android, ios }

/// Device OS to test on.
DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android;

/// Discovers available devices and chooses one to work with.
abstract class DeviceDiscovery {
  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 38 39 40 41
  /// 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].
42
  Future<void> chooseWorkingDevice();
43

44 45 46 47
  /// A device to work with.
  ///
  /// Returns the same device when called repeatedly (unlike
  /// [chooseWorkingDevice]). This is useful when you need to perform multiple
48
  /// operations on one.
49
  Future<Device> get workingDevice;
50

51 52
  /// Lists all available devices' IDs.
  Future<List<String>> discoverDevices();
53

54 55
  /// Checks the health of the available devices.
  Future<Map<String, HealthCheckResult>> checkDevices();
56

57
  /// Prepares the system to run tasks.
58
  Future<void> performPreflightTasks();
59 60
}

61 62 63 64
/// A proxy for one specific device.
abstract class Device {
  /// A unique device identifier.
  String get deviceId;
65

66 67
  /// Whether the device is awake.
  Future<bool> isAwake();
68

69 70
  /// Whether the device is asleep.
  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 82 83
  /// Unlocks the device.
  ///
  /// 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 90 91
  /// Read memory statistics for a process.
  Future<Map<String, dynamic>> getMemoryStats(String packageName);

92 93 94 95 96 97
  /// Stream the system log from the device.
  ///
  /// Flutter applications' `print` statements end up in this log
  /// with some prefix.
  Stream<String> get logcat;

98
  /// Stop a process.
99
  Future<void> stop(String packageName);
100
}
101

102
class AndroidDeviceDiscovery implements DeviceDiscovery {
103 104 105 106 107 108
  factory AndroidDeviceDiscovery() {
    return _instance ??= AndroidDeviceDiscovery._();
  }

  AndroidDeviceDiscovery._();

109 110 111
  // Parses information about a device. Example:
  //
  // 015d172c98400a03       device usb:340787200X product:nakasi model:Nexus_7 device:grouper
112
  static final RegExp _kDeviceRegex = RegExp(r'^(\S+)\s+(\S+)(.*)');
113

114 115 116 117 118 119 120 121
  static AndroidDeviceDiscovery _instance;

  AndroidDevice _workingDevice;

  @override
  Future<AndroidDevice> get workingDevice async {
    if (_workingDevice == null) {
      await chooseWorkingDevice();
122
    }
123 124

    return _workingDevice;
125 126
  }

127 128 129
  /// Picks a random Android device out of connected devices and sets it as
  /// [workingDevice].
  @override
130
  Future<void> chooseWorkingDevice() async {
131
    final List<Device> allDevices = (await discoverDevices())
132
      .map<Device>((String id) => AndroidDevice(deviceId: id))
133 134 135 136 137 138
      .toList();

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

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

142 143
  @override
  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 148
    for (String line in output) {
      // Skip lines like: * daemon started successfully *
149 150
      if (line.startsWith('* daemon '))
        continue;
151

152 153
      if (line.startsWith('List of devices'))
        continue;
154 155

      if (_kDeviceRegex.hasMatch(line)) {
156
        final Match match = _kDeviceRegex.firstMatch(line);
157

158 159
        final String deviceID = match[1];
        final String deviceState = match[2];
160 161 162 163 164

        if (!const <String>['unauthorized', 'offline'].contains(deviceState)) {
          results.add(deviceID);
        }
      } else {
165
        throw 'Failed to parse device from adb output: "$line"';
166 167 168 169 170 171
      }
    }

    return results;
  }

172 173
  @override
  Future<Map<String, HealthCheckResult>> checkDevices() async {
174
    final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
175 176
    for (String deviceId in await discoverDevices()) {
      try {
177
        final AndroidDevice device = AndroidDevice(deviceId: deviceId);
178 179 180
        // Just a smoke test that we can read wakefulness state
        // TODO(yjbanov): check battery level
        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 187 188 189
      }
    }
    return results;
  }

  @override
190
  Future<void> performPreflightTasks() async {
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
    // 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 AndroidDevice implements Device {
  AndroidDevice({@required this.deviceId});

  @override
  final String deviceId;

207
  /// Whether the device is awake.
208
  @override
209 210 211 212 213
  Future<bool> isAwake() async {
    return await _getWakefulness() == 'Awake';
  }

  /// Whether the device is asleep.
214
  @override
215 216 217 218 219
  Future<bool> isAsleep() async {
    return await _getWakefulness() == 'Asleep';
  }

  /// Wake up the device if it is not awake using [togglePower].
220
  @override
221
  Future<void> wakeUp() async {
222 223
    if (!(await isAwake()))
      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 230
    if (!(await isAsleep()))
      await togglePower();
231 232 233 234
  }

  /// Sends `KEYCODE_POWER` (26), which causes the device to toggle its mode
  /// between awake and asleep.
235
  @override
236
  Future<void> togglePower() async {
237 238 239 240 241 242
    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.
243
  @override
244
  Future<void> unlock() async {
245 246 247 248
    await wakeUp();
    await shellExec('input', const <String>['keyevent', '82']);
  }

249
  @override
250
  Future<void> tap(int x, int y) async {
251 252 253
    await shellExec('input', <String>['tap', '$x', '$y']);
  }

254 255 256 257
  /// Retrieves device's wakefulness state.
  ///
  /// See: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/PowerManagerInternal.java
  Future<String> _getWakefulness() async {
258 259
    final String powerInfo = await shellEval('dumpsys', <String>['power']);
    final String wakefulness = grep('mWakefulness=', from: powerInfo).single.split('=')[1].trim();
260 261 262 263
    return wakefulness;
  }

  /// 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 274
  }

  /// Runs `adb` with the given [arguments], selecting this device.
  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 279

  @override
  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 284 285 286 287
    return <String, dynamic>{
      'total_kb': int.parse(match.group(1)),
    };
  }

288 289
  @override
  Stream<String> get logcat {
290 291 292 293
    final Completer<void> stdoutDone = Completer<void>();
    final Completer<void> stderrDone = Completer<void>();
    final Completer<void> processDone = Completer<void>();
    final Completer<void> abort = Completer<void>();
294 295
    bool aborted = false;
    StreamController<String> stream;
296
    stream = StreamController<String>(
297 298
      onListen: () async {
        await adb(<String>['logcat', '--clear']);
299 300 301 302 303 304 305 306 307 308
        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'],
        );
309
        process.stdout
310 311
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
312 313 314 315 316
          .listen((String line) {
            print('adb logcat: $line');
            stream.sink.add(line);
          }, onDone: () { stdoutDone.complete(); });
        process.stderr
317 318
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
319 320 321
          .listen((String line) {
            print('adb logcat stderr: $line');
          }, onDone: () { stderrDone.complete(); });
322
        process.exitCode.then<void>((int exitCode) {
323 324
          print('adb logcat process terminated with exit code $exitCode');
          if (!aborted) {
325
            stream.addError(BuildFailedError('adb logcat failed with exit code $exitCode.\n'));
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
            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;
  }

354
  @override
355
  Future<void> stop(String packageName) async {
356 357
    return shellExec('am', <String>['force-stop', packageName]);
  }
358 359 360 361
}

class IosDeviceDiscovery implements DeviceDiscovery {
  factory IosDeviceDiscovery() {
362
    return _instance ??= IosDeviceDiscovery._();
363 364 365 366
  }

  IosDeviceDiscovery._();

367 368
  static IosDeviceDiscovery _instance;

369 370 371 372 373 374 375 376 377 378 379 380 381 382
  IosDevice _workingDevice;

  @override
  Future<IosDevice> get workingDevice async {
    if (_workingDevice == null) {
      await chooseWorkingDevice();
    }

    return _workingDevice;
  }

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

388
    if (allDevices.isEmpty)
389 390 391
      throw 'No iOS devices detected';

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

395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
  // Returns the path to cached binaries relative to devicelab directory
  String get _artifactDirPath {
    return path.normalize(
      path.join(
        path.current,
        '../../bin/cache/artifacts',
      )
    );
  }

  // Returns a colon-separated environment variable that contains the paths
  // of linked libraries for idevice_id
  Map<String, String> get _ideviceIdEnvironment {
    final String libPath = const <String>[
      'libimobiledevice',
      'usbmuxd',
      'libplist',
      'openssl',
      'ideviceinstaller',
      'ios-deploy',
415
      'libzip',
416 417 418 419
    ].map((String packageName) => path.join(_artifactDirPath, packageName)).join(':');
    return <String, String>{'DYLD_LIBRARY_PATH': libPath};
  }

420 421
  @override
  Future<List<String>> discoverDevices() async {
422 423
    final String ideviceIdPath = path.join(_artifactDirPath, 'libimobiledevice', 'idevice_id');
    final List<String> iosDeviceIDs = LineSplitter.split(await eval(ideviceIdPath, <String>['-l'], environment: _ideviceIdEnvironment))
424
      .map<String>((String line) => line.trim())
425 426
      .where((String line) => line.isNotEmpty)
      .toList();
427
    if (iosDeviceIDs.isEmpty)
428
      throw 'No connected iOS devices found.';
429
    return iosDeviceIDs;
430
  }
431 432 433

  @override
  Future<Map<String, HealthCheckResult>> checkDevices() async {
434
    final Map<String, HealthCheckResult> results = <String, HealthCheckResult>{};
435
    for (String deviceId in await discoverDevices()) {
436
      // TODO(ianh): do a more meaningful connectivity check than just recording the ID
437
      results['ios-device-$deviceId'] = HealthCheckResult.success();
438 439 440 441 442
    }
    return results;
  }

  @override
443
  Future<void> performPreflightTasks() async {
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466
    // Currently we do not have preflight tasks for iOS.
  }
}

/// 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
467
  Future<void> wakeUp() async {}
468 469

  @override
470
  Future<void> sendToSleep() async {}
471 472

  @override
473
  Future<void> togglePower() async {}
474 475

  @override
476
  Future<void> unlock() async {}
477

478
  @override
479
  Future<void> tap(int x, int y) async {
480 481 482
    throw 'Not implemented';
  }

483 484 485 486 487
  @override
  Future<Map<String, dynamic>> getMemoryStats(String packageName) async {
    throw 'Not implemented';
  }

488 489 490 491 492
  @override
  Stream<String> get logcat {
    throw 'Not implemented';
  }

493
  @override
494
  Future<void> stop(String packageName) async {}
495 496 497 498
}

/// Path to the `adb` executable.
String get adbPath {
499
  final String androidHome = Platform.environment['ANDROID_HOME'] ?? Platform.environment['ANDROID_SDK_ROOT'];
500 501

  if (androidHome == null)
502 503 504
    throw 'The ANDROID_SDK_ROOT and ANDROID_HOME environment variables are '
        'missing. At least one of these variables must point to the Android '
        'SDK directory containing platform-tools.';
505

506
  final String adbPath = path.join(androidHome, 'platform-tools/adb');
507

508 509
  if (!canRun(adbPath))
    throw 'adb not found at: $adbPath';
510

511
  return path.absolute(adbPath);
512
}