Flutter attach (#18677)

Add `flutter attach` command.

This command allows Flutter to connect to applications that haven't been launched using `flutter run`.
diff --git a/dev/devicelab/bin/tasks/flutter_attach_test.dart b/dev/devicelab/bin/tasks/flutter_attach_test.dart
new file mode 100644
index 0000000..e1333c7
--- /dev/null
+++ b/dev/devicelab/bin/tasks/flutter_attach_test.dart
@@ -0,0 +1,145 @@
+// Copyright (c) 2018 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';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+import 'package:flutter_devicelab/framework/adb.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:flutter_devicelab/framework/utils.dart';
+
+Future<void> testReload(Process process, { Future<void> Function() onListening }) async {
+  section('Testing hot reload, restart and quit');
+  final Completer<Null> listening = new Completer<Null>();
+  final Completer<Null> ready = new Completer<Null>();
+  final Completer<Null> reloaded = new Completer<Null>();
+  final Completer<Null> restarted = new Completer<Null>();
+  final Completer<Null> finished = new Completer<Null>();
+  final List<String> stdout = <String>[];
+  final List<String> stderr = <String>[];
+
+  if (onListening == null)
+    listening.complete();
+
+  int exitCode;
+  process.stdout
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen((String line) {
+    print('attach:stdout: $line');
+    stdout.add(line);
+    if (line.contains('Listening') && onListening != null) {
+      listening.complete(onListening());
+    }
+    if (line.contains('To quit, press "q".'))
+      ready.complete();
+    if (line.contains('Reloaded '))
+      reloaded.complete();
+    if (line.contains('Restarted app in '))
+      restarted.complete();
+    if (line.contains('Application finished'))
+      finished.complete();
+  });
+  process.stderr
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen((String line) {
+    print('run:stderr: $line');
+    stdout.add(line);
+  });
+
+  process.exitCode.then((int processExitCode) { exitCode = processExitCode; });
+
+  Future<dynamic> eventOrExit(Future<Null> event) {
+    return Future.any<dynamic>(<Future<dynamic>>[ event, process.exitCode ]);
+  }
+
+  await eventOrExit(listening.future);
+  await eventOrExit(ready.future);
+
+  if (exitCode != null)
+    throw 'Failed to attach to test app; command unexpected exited, with exit code $exitCode.';
+
+  process.stdin.write('r');
+  process.stdin.flush();
+  await eventOrExit(reloaded.future);
+  process.stdin.write('R');
+  process.stdin.flush();
+  await eventOrExit(restarted.future);
+  process.stdin.write('q');
+  process.stdin.flush();
+  await eventOrExit(finished.future);
+
+  await process.exitCode;
+
+  if (stderr.isNotEmpty)
+    throw 'flutter attach had output on standard error.';
+
+  if (exitCode != 0)
+    throw 'exit code was not 0';
+}
+
+void main() {
+  const String kAppId = 'com.yourcompany.integration_ui';
+  const String kActivityId = '$kAppId/com.yourcompany.integration_ui.MainActivity';
+
+  task(() async {
+    final AndroidDevice device = await devices.workingDevice;
+    await device.unlock();
+    final Directory appDir = dir(path.join(flutterDirectory.path, 'dev/integration_tests/ui'));
+    await inDirectory(appDir, () async {
+      section('Build: starting...');
+      final String buildStdout = await eval(
+          path.join(flutterDirectory.path, 'bin', 'flutter'),
+          <String>['--suppress-analytics', 'build', 'apk', '--debug', 'lib/main.dart'],
+      );
+      final String lastLine = buildStdout.split('\n').last;
+      final RegExp builtRegExp = new RegExp(r'Built (.+)( \(|\.$)');
+      final String apkPath = builtRegExp.firstMatch(lastLine)[1];
+
+      section('Installing $apkPath');
+
+      await device.adb(<String>['install', apkPath]);
+
+      try {
+        section('Launching attach.');
+        Process attachProcess = await startProcess(
+          path.join(flutterDirectory.path, 'bin', 'flutter'),
+          <String>['--suppress-analytics', 'attach', '-d', device.deviceId],
+          isBot: false, // we just want to test the output, not have any debugging info
+        );
+
+        await testReload(attachProcess, onListening: () async {
+          section('Launching app.');
+          await device.shellExec('am', <String>['start', '-n', kActivityId]);
+        });
+
+        final String currentTime = (await device.shellEval('date', <String>['"+%F %R:%S.000"'])).trim();
+        print('Start time on device: $currentTime');
+        section('Launching app');
+        await device.shellExec('am', <String>['start', '-n', kActivityId]);
+
+        final String observatoryLine = await device.adb(<String>['logcat', '-e', 'Observatory listening on http:', '-m', '1', '-T', currentTime]);
+        print('Found observatory line: $observatoryLine');
+        final String observatoryPort = new RegExp(r'Observatory listening on http://.*:([0-9]+)').firstMatch(observatoryLine)[1];
+        print('Extracted observatory port: $observatoryPort');
+
+        section('Launching attach with given port.');
+        attachProcess = await startProcess(
+          path.join(flutterDirectory.path, 'bin', 'flutter'),
+          <String>['--suppress-analytics', 'attach', '--debug-port', observatoryPort, '-d', device.deviceId],
+          isBot: false, // we just want to test the output, not have any debugging info
+        );
+        await testReload(attachProcess);
+
+      } finally {
+        section('Uninstalling');
+        await device.adb(<String>['uninstall', kAppId]);
+      }
+    });
+    return new TaskResult.success(null);
+  });
+}
diff --git a/dev/devicelab/lib/framework/adb.dart b/dev/devicelab/lib/framework/adb.dart
index 958f784..de65020 100644
--- a/dev/devicelab/lib/framework/adb.dart
+++ b/dev/devicelab/lib/framework/adb.dart
@@ -248,12 +248,17 @@
 
   /// Executes [command] on `adb shell` and returns its exit code.
   Future<Null> shellExec(String command, List<String> arguments, { Map<String, String> environment }) async {
-    await exec(adbPath, <String>['shell', command]..addAll(arguments), environment: environment, canFail: false);
+    await adb(<String>['shell', command]..addAll(arguments), environment: environment);
   }
 
   /// Executes [command] on `adb shell` and returns its standard output as a [String].
   Future<String> shellEval(String command, List<String> arguments, { Map<String, String> environment }) {
-    return eval(adbPath, <String>['shell', command]..addAll(arguments), environment: environment, canFail: false);
+    return adb(<String>['shell', command]..addAll(arguments), environment: environment);
+  }
+
+  /// Runs `adb` with the given [arguments], selecting this device.
+  Future<String> adb(List<String> arguments, { Map<String, String> environment }) {
+    return eval(adbPath, <String>['-s', deviceId]..addAll(arguments), environment: environment, canFail: false);
   }
 
   @override
diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml
index 08a488c..148faf5 100644
--- a/dev/devicelab/manifest.yaml
+++ b/dev/devicelab/manifest.yaml
@@ -269,6 +269,12 @@
     stage: devicelab
     required_agent_capabilities: ["linux/android"]
 
+  flutter_attach_test:
+    description: >
+      Tests the `flutter attach` command.
+    stage: devicelab
+    required_agent_capabilities: ["linux/android"]
+
   # iOS on-device tests
 
   flavors_test_ios:
diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart
index 2d466d4..b07ba18 100644
--- a/packages/flutter_tools/lib/executable.dart
+++ b/packages/flutter_tools/lib/executable.dart
@@ -6,6 +6,7 @@
 
 import 'runner.dart' as runner;
 import 'src/commands/analyze.dart';
+import 'src/commands/attach.dart';
 import 'src/commands/build.dart';
 import 'src/commands/channel.dart';
 import 'src/commands/clean.dart';
@@ -48,6 +49,7 @@
 
   await runner.run(args, <FlutterCommand>[
     new AnalyzeCommand(verboseHelp: verboseHelp),
+    new AttachCommand(verboseHelp: verboseHelp),
     new BuildCommand(verboseHelp: verboseHelp),
     new ChannelCommand(verboseHelp: verboseHelp),
     new CleanCommand(),
diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
new file mode 100644
index 0000000..13fe2e1
--- /dev/null
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -0,0 +1,105 @@
+// Copyright 2018 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';
+
+import '../base/common.dart';
+import '../base/io.dart';
+import '../cache.dart';
+import '../device.dart';
+import '../protocol_discovery.dart';
+import '../resident_runner.dart';
+import '../run_hot.dart';
+import '../runner/flutter_command.dart';
+
+final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
+
+/// A Flutter-command that attaches to applications that have been launched
+/// without `flutter run`.
+///
+/// With an application already running, a HotRunner can be attached to it
+/// with:
+/// ```
+/// $ flutter attach --debug-port 12345
+/// ```
+///
+/// Alternatively, the attach command can start listening and scan for new
+/// programs that become active:
+/// ```
+/// $ flutter attach
+/// ```
+/// As soon as a new observatory is detected the command attaches to it and
+/// enables hot reloading.
+class AttachCommand extends FlutterCommand {
+  AttachCommand({bool verboseHelp = false}) {
+    addBuildModeFlags(defaultToRelease: false);
+    argParser.addOption(
+        'debug-port',
+        help: 'Local port where the observatory is listening.',
+    );
+    argParser.addFlag(
+      'preview-dart-2',
+      defaultsTo: true,
+      hide: !verboseHelp,
+      help: 'Preview Dart 2.0 functionality.',
+    );
+  }
+
+  @override
+  final String name = 'attach';
+
+  @override
+  final String description = 'Attach to a running application.';
+
+  int get observatoryPort {
+    if (argResults['debug-port'] == null)
+      return null;
+    try {
+      return int.parse(argResults['debug-port']);
+    } catch (error) {
+      throwToolExit('Invalid port for `--debug-port`: $error');
+    }
+    return null;
+  }
+
+  @override
+  Future<Null> runCommand() async {
+    Cache.releaseLockEarly();
+
+    await _validateArguments();
+
+    final Device device = await findTargetDevice();
+    final int devicePort = observatoryPort;
+    Uri observatoryUri;
+    if (devicePort == null) {
+      ProtocolDiscovery observatoryDiscovery;
+      try {
+        observatoryDiscovery = new ProtocolDiscovery.observatory(
+            device.getLogReader(), portForwarder: device.portForwarder);
+        print('Listening.');
+        observatoryUri = await observatoryDiscovery.uri;
+      } finally {
+        await observatoryDiscovery?.cancel();
+      }
+    } else {
+      final int localPort = await device.portForwarder.forward(devicePort);
+      observatoryUri = Uri.parse('http://$ipv4Loopback:$localPort/');
+    }
+    try {
+      final FlutterDevice flutterDevice =
+          new FlutterDevice(device, trackWidgetCreation: false, previewDart2: argResults['preview-dart-2']);
+      flutterDevice.observatoryUris = <Uri>[ observatoryUri ];
+      final HotRunner hotRunner = new HotRunner(
+        <FlutterDevice>[flutterDevice],
+        debuggingOptions: new DebuggingOptions.enabled(getBuildInfo()),
+        packagesFilePath: globalResults['packages'],
+      );
+      await hotRunner.attach();
+    } finally {
+      device.portForwarder.forwardedPorts.forEach(device.portForwarder.unforward);
+    }
+  }
+
+  Future<void> _validateArguments() async {}
+}
diff --git a/packages/flutter_tools/test/commands/attach_test.dart b/packages/flutter_tools/test/commands/attach_test.dart
new file mode 100644
index 0000000..e550b93
--- /dev/null
+++ b/packages/flutter_tools/test/commands/attach_test.dart
@@ -0,0 +1,74 @@
+// Copyright 2018 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';
+
+import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/commands/attach.dart';
+import 'package:flutter_tools/src/device.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+import '../src/mocks.dart';
+
+void main() {
+  group('attach', () {
+    setUpAll(() {
+      Cache.disableLocking();
+    });
+
+    testUsingContext('finds observatory port and forwards', () async {
+      const int devicePort = 499;
+      const int hostPort = 42;
+      final MockDeviceLogReader mockLogReader = new MockDeviceLogReader();
+      final MockPortForwarder portForwarder = new MockPortForwarder();
+      final MockAndroidDevice device = new MockAndroidDevice();
+      when(device.getLogReader()).thenAnswer((_) {
+        // Now that the reader is used, start writing messages to it.
+        Timer.run(() {
+          mockLogReader.addLine('Foo');
+          mockLogReader.addLine('Observatory listening on http://127.0.0.1:$devicePort');
+        });
+
+        return mockLogReader;
+      });
+      when(device.portForwarder).thenReturn(portForwarder);
+      when(portForwarder.forward(devicePort, hostPort: anyNamed('hostPort'))).thenAnswer((_) async => hostPort);
+      when(portForwarder.forwardedPorts).thenReturn(<ForwardedPort>[new ForwardedPort(hostPort, devicePort)]);
+      when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null);
+      testDeviceManager.addDevice(device);
+
+      final AttachCommand command = new AttachCommand();
+
+      await createTestCommandRunner(command).run(<String>['attach']);
+
+      verify(portForwarder.forward(devicePort, hostPort: anyNamed('hostPort'))).called(1);
+
+      mockLogReader.dispose();
+    });
+
+    testUsingContext('forwards to given port', () async {
+      const int devicePort = 499;
+      const int hostPort = 42;
+      final MockPortForwarder portForwarder = new MockPortForwarder();
+      final MockAndroidDevice device = new MockAndroidDevice();
+
+      when(device.portForwarder).thenReturn(portForwarder);
+      when(portForwarder.forward(devicePort)).thenAnswer((_) async => hostPort);
+      when(portForwarder.forwardedPorts).thenReturn(<ForwardedPort>[new ForwardedPort(hostPort, devicePort)]);
+      when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null);
+      testDeviceManager.addDevice(device);
+
+      final AttachCommand command = new AttachCommand();
+
+      await createTestCommandRunner(command).run(<String>['attach', '--debug-port', '$devicePort']);
+
+      verify(portForwarder.forward(devicePort)).called(1);
+    });
+  });
+}
+
+class MockPortForwarder extends Mock implements DevicePortForwarder {}