android_device.dart 19.9 KB
Newer Older
1 2 3 4 5
// Copyright 2016 The Chromium 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';
6
import 'dart:convert';
7 8 9 10
import 'dart:io';

import 'package:path/path.dart' as path;

11
import '../android/android_sdk.dart';
12
import '../application_package.dart';
13 14
import '../base/common.dart';
import '../base/os.dart';
15 16 17 18
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../flx.dart' as flx;
19
import '../globals.dart';
20
import '../service_protocol.dart';
21
import '../toolchain.dart';
22
import 'adb.dart';
23 24
import 'android.dart';

25 26
const String _defaultAdbPath = 'adb';

27 28 29 30 31 32
// Path where the FLX bundle will be copied on the device.
const String _deviceBundlePath = '/data/local/tmp/dev.flx';

// Path where the snapshot will be copied on the device.
const String _deviceSnapshotPath = '/data/local/tmp/dev_snapshot.bin';

33 34
class AndroidDevices extends PollingDeviceDiscovery {
  AndroidDevices() : super('AndroidDevices');
35

36
  @override
37
  bool get supportsPlatform => true;
38 39

  @override
40
  List<Device> pollingGetDevices() => getAdbDevices();
41 42
}

43
class AndroidDevice extends Device {
44 45 46 47
  AndroidDevice(
    String id, {
    this.productID,
    this.modelID,
48 49
    this.deviceCodeName
  }) : super(id);
50

51 52 53 54
  final String productID;
  final String modelID;
  final String deviceCodeName;

55 56
  bool _isLocalEmulator;

57
  @override
58 59
  bool get isLocalEmulator {
    if (_isLocalEmulator == null) {
60
      // http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...)
61
      String value = runCheckedSync(adbCommandForDevice(['shell', 'getprop', 'ro.product.cpu.abi']));
62
      _isLocalEmulator = value.startsWith('x86');
63 64 65 66
    }

    return _isLocalEmulator;
  }
67

68
  _AdbLogReader _logReader;
69
  _AndroidDevicePortForwarder _portForwarder;
70

71
  List<String> adbCommandForDevice(List<String> args) {
72
    return <String>[androidSdk.adbPath, '-s', id]..addAll(args);
73 74 75 76
  }

  bool _isValidAdbVersion(String adbVersion) {
    // Sample output: 'Android Debug Bridge version 1.0.31'
77
    Match versionFields = new RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion);
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
    if (versionFields != null) {
      int majorVersion = int.parse(versionFields[1]);
      int minorVersion = int.parse(versionFields[2]);
      int patchVersion = int.parse(versionFields[3]);
      if (majorVersion > 1) {
        return true;
      }
      if (majorVersion == 1 && minorVersion > 0) {
        return true;
      }
      if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 32) {
        return true;
      }
      return false;
    }
93
    printError(
94 95 96 97
        'Unrecognized adb version string $adbVersion. Skipping version check.');
    return true;
  }

98 99 100 101
  bool _checkForSupportedAdbVersion() {
    if (androidSdk == null)
      return false;

102
    try {
103 104
      String adbVersion = runCheckedSync(<String>[androidSdk.adbPath, 'version']);
      if (_isValidAdbVersion(adbVersion))
105
        return true;
106 107 108
      printError('The ADB at "${androidSdk.adbPath}" is too old; please install version 1.0.32 or later.');
    } catch (error, trace) {
      printError('Error running ADB: $error', trace);
109
    }
110

111 112 113 114 115 116 117 118 119
    return false;
  }

  bool _checkForSupportedAndroidVersion() {
    try {
      // If the server is automatically restarted, then we get irrelevant
      // output lines like this, which we want to ignore:
      //   adb server is out of date.  killing..
      //   * daemon started successfully *
120
      runCheckedSync(<String>[androidSdk.adbPath, 'start-server']);
121 122

      // Sample output: '22'
123 124 125
      String sdkVersion = runCheckedSync(
        adbCommandForDevice(<String>['shell', 'getprop', 'ro.build.version.sdk'])
      ).trimRight();
126

127
      int sdkVersionParsed = int.parse(sdkVersion, onError: (String source) => null);
128
      if (sdkVersionParsed == null) {
129
        printError('Unexpected response from getprop: "$sdkVersion"');
130 131
        return false;
      }
132

133
      if (sdkVersionParsed < minApiLevel) {
134
        printError(
135 136 137 138
          'The Android version ($sdkVersion) on the target device is too old. Please '
          'use a $minVersionName (version $minApiLevel / $minVersionText) device or later.');
        return false;
      }
139

140 141
      return true;
    } catch (e) {
142
      printError('Unexpected failure from adb: $e');
143
      return false;
144 145 146 147 148 149 150 151
    }
  }

  String _getDeviceSha1Path(ApplicationPackage app) {
    return '/data/local/tmp/sky.${app.id}.sha1';
  }

  String _getDeviceApkSha1(ApplicationPackage app) {
152
    return runCheckedSync(adbCommandForDevice(<String>['shell', 'cat', _getDeviceSha1Path(app)]));
153 154 155
  }

  String _getSourceSha1(ApplicationPackage app) {
Devon Carew's avatar
Devon Carew committed
156 157
    File shaFile = new File('${app.localPath}.sha1');
    return shaFile.existsSync() ? shaFile.readAsStringSync() : '';
158 159
  }

160
  @override
161 162 163 164
  String get name => modelID;

  @override
  bool isAppInstalled(ApplicationPackage app) {
165 166 167 168 169
    // This call takes 400ms - 600ms.
    if (runCheckedSync(adbCommandForDevice(['shell', 'pm', 'path', app.id])).isEmpty)
      return false;

    // Check the application SHA.
Devon Carew's avatar
Devon Carew committed
170
    return _getDeviceApkSha1(app) == _getSourceSha1(app);
171 172 173 174 175
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!FileSystemEntity.isFileSync(app.localPath)) {
176
      printError('"${app.localPath}" does not exist.');
177 178 179
      return false;
    }

180 181 182
    if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion())
      return false;

183 184 185
    printStatus('Installing ${app.name} on device.');
    runCheckedSync(adbCommandForDevice(<String>['install', '-r', app.localPath]));
    runCheckedSync(adbCommandForDevice(<String>['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]));
186 187 188
    return true;
  }

189 190
  Future<Null> _forwardObservatoryPort(int devicePort, int port) async {
    bool portWasZero = (port == null) || (port == 0);
191

192
    try {
193
      // Set up port forwarding for observatory.
194
      port = await portForwarder.forward(devicePort,
195
                                         hostPort: port);
196 197
      if (portWasZero)
        printStatus('Observatory listening on http://127.0.0.1:$port');
198
    } catch (e) {
199
      printError('Unable to forward Observatory port $port: $e');
200 201 202
    }
  }

203
  Future<bool> startBundle(AndroidApk apk, String bundlePath, {
204 205 206
    bool checked: true,
    bool traceStartup: false,
    String route,
207 208 209 210
    bool clearLogs: false,
    bool startPaused: false,
    int debugPort: observatoryDefaultPort
  }) async {
211
    printTrace('$this startBundle');
212 213

    if (!FileSystemEntity.isFileSync(bundlePath)) {
214
      printError('Cannot find $bundlePath');
215 216 217 218 219 220
      return false;
    }

    if (clearLogs)
      this.clearLogs();

221
    runCheckedSync(adbCommandForDevice(<String>['push', bundlePath, _deviceBundlePath]));
222

223 224 225
    ServiceProtocolDiscovery serviceProtocolDiscovery =
        new ServiceProtocolDiscovery(logReader);

Devon Carew's avatar
Devon Carew committed
226 227 228
    // We take this future here but do not wait for completion until *after* we
    // start the bundle.
    Future<int> scrapeServicePort = serviceProtocolDiscovery.nextPort();
229

230
    List<String> cmd = adbCommandForDevice(<String>[
231 232
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
233
      '-d', _deviceBundlePath,
234
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
235
      '--ez', 'enable-background-compilation', 'true',
236 237
    ]);
    if (checked)
238
      cmd.addAll(<String>['--ez', 'enable-checked-mode', 'true']);
239
    if (traceStartup)
240
      cmd.addAll(<String>['--ez', 'trace-startup', 'true']);
241
    if (startPaused)
242
      cmd.addAll(<String>['--ez', 'start-paused', 'true']);
243
    if (route != null)
244
      cmd.addAll(<String>['--es', 'route', route]);
245
    cmd.add(apk.launchActivity);
246 247 248 249 250 251
    String result = runCheckedSync(cmd);
    // This invocation returns 0 even when it fails.
    if (result.contains('Error: ')) {
      printError(result.trim());
      return false;
    }
252

Devon Carew's avatar
Devon Carew committed
253 254 255
    // Wait for the service protocol port here. This will complete once the
    // device has printed "Observatory is listening on...".
    printTrace('Waiting for observatory port to be available...');
256

Devon Carew's avatar
Devon Carew committed
257 258 259 260 261 262 263 264 265 266 267 268 269
    try {
      int devicePort = await scrapeServicePort.timeout(new Duration(seconds: 12));
      printTrace('service protocol port = $devicePort');
      await _forwardObservatoryPort(devicePort, debugPort);
      return true;
    } catch (error) {
      if (error is TimeoutException)
        printError('Timed out while waiting for a debug connection.');
      else
        printError('Error waiting for a debug connection: $error');

      return false;
    }
270 271 272 273 274 275 276 277 278
  }

  @override
  Future<bool> startApp(
    ApplicationPackage package,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
Devon Carew's avatar
Devon Carew committed
279
    bool clearLogs: false,
280 281
    bool startPaused: false,
    int debugPort: observatoryDefaultPort,
282
    Map<String, dynamic> platformArgs
283
  }) async {
284 285 286
    if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion())
      return false;

Devon Carew's avatar
Devon Carew committed
287
    String localBundlePath = await flx.buildFlx(
288 289
      toolchain,
      mainPath: mainPath
290 291 292 293
    );

    printTrace('Starting bundle for $this.');

Devon Carew's avatar
Devon Carew committed
294 295 296 297 298 299 300 301 302 303 304 305 306
    if (await startBundle(
      package,
      localBundlePath,
      checked: checked,
      traceStartup: platformArgs['trace-startup'],
      route: route,
      clearLogs: clearLogs,
      startPaused: startPaused,
      debugPort: debugPort
    )) {
      return true;
    } else {
      return false;
307
    }
308 309
  }

310
  @override
311 312 313
  Future<bool> stopApp(ApplicationPackage app) {
    List<String> command = adbCommandForDevice(<String>['shell', 'am', 'force-stop', app.id]);
    return runCommandAndStreamOutput(command).then((int exitCode) => exitCode == 0);
314 315
  }

Devon Carew's avatar
Devon Carew committed
316
  // TODO(devoncarew): Use isLocalEmulator to return android_arm or android_x64.
317
  @override
318
  TargetPlatform get platform => TargetPlatform.android_arm;
319

320
  @override
321
  void clearLogs() {
322
    runSync(adbCommandForDevice(<String>['logcat', '-c']));
323 324
  }

325
  @override
326 327 328 329 330
  DeviceLogReader get logReader {
    if (_logReader == null)
      _logReader = new _AdbLogReader(this);
    return _logReader;
  }
331

332
  @override
333 334 335 336 337 338 339
  DevicePortForwarder get portForwarder {
    if (_portForwarder == null)
      _portForwarder = new _AndroidDevicePortForwarder(this);

    return _portForwarder;
  }

340
  void startTracing(AndroidApk apk) {
341
    runCheckedSync(adbCommandForDevice(<String>[
342 343 344 345 346 347 348 349
      'shell',
      'am',
      'broadcast',
      '-a',
      '${apk.id}.TRACING_START'
    ]));
  }

350 351
  /// Return the most recent timestamp in the Android log or `null` if there is
  /// no available timestamp. The format can be passed to logcat's -T option.
352
  String get lastLogcatTimestamp {
353
    String output = runCheckedSync(adbCommandForDevice(<String>[
354
      'logcat', '-v', 'time', '-t', '1'
355
    ]));
356 357 358

    RegExp timeRegExp = new RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}', multiLine: true);
    Match timeMatch = timeRegExp.firstMatch(output);
359
    return timeMatch?.group(0);
360 361
  }

362
  Future<String> stopTracing(AndroidApk apk, { String outPath }) async {
363 364
    // Workaround for logcat -c not always working:
    // http://stackoverflow.com/questions/25645012/logcat-on-android-l-not-clearing-after-unplugging-and-reconnecting
365
    String beforeStop = lastLogcatTimestamp;
366
    runCheckedSync(adbCommandForDevice(<String>[
367 368 369 370 371 372 373 374 375 376
      'shell',
      'am',
      'broadcast',
      '-a',
      '${apk.id}.TRACING_STOP'
    ]));

    RegExp traceRegExp = new RegExp(r'Saving trace to (\S+)', multiLine: true);
    RegExp completeRegExp = new RegExp(r'Trace complete', multiLine: true);

Ian Hickson's avatar
Ian Hickson committed
377
    String tracePath;
378 379
    bool isComplete = false;
    while (!isComplete) {
380
      List<String> args = <String>['logcat', '-d'];
381 382 383
      if (beforeStop != null)
        args.addAll(<String>['-T', beforeStop]);
      String logs = runCheckedSync(adbCommandForDevice(args));
384 385 386 387 388 389 390 391 392
      Match fileMatch = traceRegExp.firstMatch(logs);
      if (fileMatch != null && fileMatch[1] != null) {
        tracePath = fileMatch[1];
      }
      isComplete = completeRegExp.hasMatch(logs);
    }

    if (tracePath != null) {
      String localPath = (outPath != null) ? outPath : path.basename(tracePath);
393

394
      // Run cat via ADB to print the captured trace file. (adb pull will be unable
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409
      // to access the file if it does not have root permissions)
      IOSink catOutput = new File(localPath).openWrite();
      List<String> catCommand = adbCommandForDevice(
          <String>['shell', 'run-as', apk.id, 'cat', tracePath]
      );
      Process catProcess = await Process.start(catCommand[0],
          catCommand.getRange(1, catCommand.length).toList());
      catProcess.stdout.pipe(catOutput);
      int exitCode = await catProcess.exitCode;
      if (exitCode != 0)
        throw 'Error code $exitCode returned when running ${catCommand.join(" ")}';

      runSync(adbCommandForDevice(
          <String>['shell', 'run-as', apk.id, 'rm', tracePath]
      ));
410 411
      return localPath;
    }
412
    printError('No trace file detected. '
413 414 415 416
        'Did you remember to start the trace before stopping it?');
    return null;
  }

417
  @override
418 419
  bool isSupported() => true;

420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438
  Future<bool> refreshSnapshot(AndroidApk apk, String snapshotPath) async {
    if (!FileSystemEntity.isFileSync(snapshotPath)) {
      printError('Cannot find $snapshotPath');
      return false;
    }

    runCheckedSync(adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath]));

    List<String> cmd = adbCommandForDevice(<String>[
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
      '-d', _deviceBundlePath,
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
      '--es', 'snapshot', _deviceSnapshotPath,
      apk.launchActivity,
    ]);
    runCheckedSync(cmd);
    return true;
  }
439
}
440

441
List<AndroidDevice> getAdbDevices() {
442 443
  String adbPath = getAdbPath(androidSdk);
  if (adbPath == null)
444
    return <AndroidDevice>[];
445

446
  List<AndroidDevice> devices = [];
447

448
  List<String> output = runSync(<String>[adbPath, 'devices', '-l']).trim().split('\n');
449 450 451 452 453 454 455 456 457 458

  // 015d172c98400a03       device usb:340787200X product:nakasi model:Nexus_7 device:grouper
  RegExp deviceRegex1 = new RegExp(
      r'^(\S+)\s+device\s+.*product:(\S+)\s+model:(\S+)\s+device:(\S+)$');

  // 0149947A0D01500C       device usb:340787200X
  RegExp deviceRegex2 = new RegExp(r'^(\S+)\s+device\s+\S+$');
  RegExp unauthorizedRegex = new RegExp(r'^(\S+)\s+unauthorized\s+\S+$');
  RegExp offlineRegex = new RegExp(r'^(\S+)\s+offline\s+\S+$');

459
  // Skip the first line, which is always 'List of devices attached'.
460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
  for (String line in output.skip(1)) {
    // Skip lines like:
    // * daemon not running. starting it now on port 5037 *
    // * daemon started successfully *
    if (line.startsWith('* daemon '))
      continue;

    if (line.startsWith('List of devices'))
      continue;

    if (deviceRegex1.hasMatch(line)) {
      Match match = deviceRegex1.firstMatch(line);
      String deviceID = match[1];
      String productID = match[2];
      String modelID = match[3];
      String deviceCodeName = match[4];

      if (modelID != null)
478
        modelID = cleanAdbDeviceName(modelID);
479 480

      devices.add(new AndroidDevice(
481 482 483
        deviceID,
        productID: productID,
        modelID: modelID,
484
        deviceCodeName: deviceCodeName
485 486 487 488
      ));
    } else if (deviceRegex2.hasMatch(line)) {
      Match match = deviceRegex2.firstMatch(line);
      String deviceID = match[1];
489
      devices.add(new AndroidDevice(deviceID));
490 491 492
    } else if (unauthorizedRegex.hasMatch(line)) {
      Match match = unauthorizedRegex.firstMatch(line);
      String deviceID = match[1];
493
      printError(
494 495 496 497 498 499
        'Device $deviceID is not authorized.\n'
        'You might need to check your device for an authorization dialog.'
      );
    } else if (offlineRegex.hasMatch(line)) {
      Match match = offlineRegex.firstMatch(line);
      String deviceID = match[1];
500
      printError('Device $deviceID is offline.');
501
    } else {
502
      printError(
503 504 505 506 507 508 509 510
        'Unexpected failure parsing device information from adb output:\n'
        '$line\n'
        'Please report a bug at https://github.com/flutter/flutter/issues/new');
    }
  }
  return devices;
}

511
/// A log reader that logs from `adb logcat`.
Devon Carew's avatar
Devon Carew committed
512 513 514 515 516
class _AdbLogReader extends DeviceLogReader {
  _AdbLogReader(this.device);

  final AndroidDevice device;

517 518 519 520
  final StreamController<String> _linesStreamController =
      new StreamController<String>.broadcast();

  Process _process;
Ian Hickson's avatar
Ian Hickson committed
521 522
  StreamSubscription<String> _stdoutSubscription;
  StreamSubscription<String> _stderrSubscription;
523

524
  @override
525 526
  Stream<String> get lines => _linesStreamController.stream;

527
  @override
528
  String get name => device.name;
Devon Carew's avatar
Devon Carew committed
529

530
  @override
531 532
  bool get isReading => _process != null;

533
  @override
Ian Hickson's avatar
Ian Hickson committed
534
  Future<int> get finished => _process != null ? _process.exitCode : new Future<int>.value(0);
535

536
  @override
Ian Hickson's avatar
Ian Hickson committed
537
  Future<Null> start() async {
538 539
    if (_process != null)
      throw new StateError('_AdbLogReader must be stopped before it can be started.');
540 541

    // Start the adb logcat process.
542
    List<String> args = <String>['logcat', '-v', 'tag'];
543 544 545
    String lastTimestamp = device.lastLogcatTimestamp;
    if (lastTimestamp != null)
      args.addAll(<String>['-T', lastTimestamp]);
Devon Carew's avatar
Devon Carew committed
546 547 548
    args.addAll(<String>[
      '-s', 'flutter:V', 'SkyMain:V', 'AndroidRuntime:W', 'ActivityManager:W', 'System.err:W', '*:F'
    ]);
549
    _process = await runCommand(device.adbCommandForDevice(args));
550 551 552 553 554 555 556 557 558
    _stdoutSubscription =
        _process.stdout.transform(UTF8.decoder)
                       .transform(const LineSplitter()).listen(_onLine);
    _stderrSubscription =
        _process.stderr.transform(UTF8.decoder)
                       .transform(const LineSplitter()).listen(_onLine);
    _process.exitCode.then(_onExit);
  }

559
  @override
Ian Hickson's avatar
Ian Hickson committed
560
  Future<Null> stop() async {
561 562 563
    if (_process == null)
      throw new StateError('_AdbLogReader must be started before it can be stopped.');

564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580
    _stdoutSubscription?.cancel();
    _stdoutSubscription = null;
    _stderrSubscription?.cancel();
    _stderrSubscription = null;
    await _process.kill();
    _process = null;
  }

  void _onExit(int exitCode) {
    _stdoutSubscription?.cancel();
    _stdoutSubscription = null;
    _stderrSubscription?.cancel();
    _stderrSubscription = null;
    _process = null;
  }

  void _onLine(String line) {
581 582 583 584
    // Filter out some noisy ActivityManager notifications.
    if (line.startsWith('W/ActivityManager: getRunningAppProcesses'))
      return;

585
    _linesStreamController.add(line);
Devon Carew's avatar
Devon Carew committed
586 587
  }

588
  @override
Devon Carew's avatar
Devon Carew committed
589 590
  int get hashCode => name.hashCode;

591
  @override
Devon Carew's avatar
Devon Carew committed
592 593 594
  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
595 596 597
    if (other is! _AdbLogReader)
      return false;
    return other.device.id == device.id;
Devon Carew's avatar
Devon Carew committed
598 599
  }
}
600 601 602 603 604 605 606 607 608 609

class _AndroidDevicePortForwarder extends DevicePortForwarder {
  _AndroidDevicePortForwarder(this.device);

  final AndroidDevice device;

  static int _extractPort(String portString) {
    return int.parse(portString.trim(), onError: (_) => null);
  }

610
  @override
611 612 613
  List<ForwardedPort> get forwardedPorts {
    final List<ForwardedPort> ports = <ForwardedPort>[];

614 615 616
    String stdout = runCheckedSync(device.adbCommandForDevice(
      <String>['forward', '--list']
    ));
617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641

    List<String> lines = LineSplitter.split(stdout).toList();
    for (String line in lines) {
      if (line.startsWith(device.id)) {
        List<String> splitLine = line.split("tcp:");

        // Sanity check splitLine.
        if (splitLine.length != 3)
          continue;

        // Attempt to extract ports.
        int hostPort = _extractPort(splitLine[1]);
        int devicePort = _extractPort(splitLine[2]);

        // Failed, skip.
        if ((hostPort == null) || (devicePort == null))
          continue;

        ports.add(new ForwardedPort(hostPort, devicePort));
      }
    }

    return ports;
  }

642
  @override
643
  Future<int> forward(int devicePort, { int hostPort }) async {
644 645 646 647 648
    if ((hostPort == null) || (hostPort == 0)) {
      // Auto select host port.
      hostPort = await findAvailablePort();
    }

649 650 651
    runCheckedSync(device.adbCommandForDevice(
      <String>['forward', 'tcp:$hostPort', 'tcp:$devicePort']
    ));
652 653 654 655

    return hostPort;
  }

656
  @override
Ian Hickson's avatar
Ian Hickson committed
657
  Future<Null> unforward(ForwardedPort forwardedPort) async {
658 659 660
    runCheckedSync(device.adbCommandForDevice(
      <String>['forward', '--remove', 'tcp:${forwardedPort.hostPort}']
    ));
661 662
  }
}