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