Add ipv6 and observatory port support to the attach command. (#24537)

diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
index 844abb0b..312d823 100644
--- a/packages/flutter_tools/lib/src/commands/attach.dart
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -53,6 +53,9 @@
     argParser
       ..addOption(
         'debug-port',
+        help: 'Device port where the observatory is listening.',
+      )..addOption(
+        'observatory-port',
         help: 'Local port where the observatory is listening.',
       )..addOption('pid-file',
         help: 'Specify a file to write the process id to. '
@@ -67,6 +70,12 @@
         negatable: false,
         help: 'Handle machine structured JSON command input and provide output '
               'and progress in machine friendly format.',
+      )..addFlag('ipv6',
+        hide: true,
+        negatable: false,
+        help: 'Binds to IPv6 localhost instead of IPv4 when the flutter tool '
+              'forwards the host port to a device port. Not used when the '
+              '--debug-port flag is not set.',
       );
     hotRunnerFactory ??= HotRunnerFactory();
   }
@@ -79,6 +88,14 @@
   @override
   final String description = 'Attach to a running application.';
 
+  // TODO(djshuckerow): this is now a confusing name. An explanation:
+  // The --observatory-port flag passed to `flutter run` is used to
+  // set up the port on the development macine that the Dart observatory
+  // listens to. This flag serves the same purpose in this command.
+  //
+  // The --debug-port flag passed only to `flutter attach` is used to
+  // set up the port on the device running a Flutter app to connect back
+  // to the host development machine.
   int get observatoryPort {
     if (argResults['debug-port'] == null)
       return null;
@@ -96,6 +113,12 @@
     if (await findTargetDevice() == null)
       throwToolExit(null);
     observatoryPort;
+    if (observatoryPort == null && argResults.wasParsed('ipv6')) {
+      throwToolExit(
+        'When the --debug-port is unknown, this command determines '
+        'the value of --ipv6 on its own.',
+      );
+    }
   }
 
   @override
@@ -163,14 +186,24 @@
           );
           printStatus('Waiting for a connection from Flutter on ${device.name}...');
           observatoryUri = await observatoryDiscovery.uri;
+          // Determine ipv6 status from the scanned logs.
+          ipv6 = observatoryDiscovery.ipv6;
           printStatus('Done.');
         } finally {
           await observatoryDiscovery?.cancel();
         }
       }
     } else {
-      final int localPort = await device.portForwarder.forward(devicePort);
-      observatoryUri = Uri.parse('http://$ipv4Loopback:$localPort/');
+      ipv6 = argResults['ipv6'];
+      // int.tryParse will throw if it is passed null, so we need to do this.
+      final int argObservatoryPort = argResults['observatory-port'] == null
+        ? null
+        : int.tryParse(argResults['observatory-port']);
+      final int localPort = argObservatoryPort
+        ?? await device.portForwarder.forward(devicePort);
+      observatoryUri = ipv6
+        ? Uri.parse('http://[$ipv6Loopback]:$localPort/')
+        : Uri.parse('http://$ipv4Loopback:$localPort/');
     }
     try {
       final FlutterDevice flutterDevice = FlutterDevice(
diff --git a/packages/flutter_tools/test/commands/attach_test.dart b/packages/flutter_tools/test/commands/attach_test.dart
index 9f4e67c..d2d725f 100644
--- a/packages/flutter_tools/test/commands/attach_test.dart
+++ b/packages/flutter_tools/test/commands/attach_test.dart
@@ -104,6 +104,7 @@
             debuggingOptions: anyNamed('debuggingOptions'),
             packagesFilePath: anyNamed('packagesFilePath'),
             usesTerminalUI: anyNamed('usesTerminalUI'),
+            ipv6: false,
           ),
         )..thenReturn(MockHotRunner());
 
@@ -134,6 +135,7 @@
             debuggingOptions: anyNamed('debuggingOptions'),
             packagesFilePath: anyNamed('packagesFilePath'),
             usesTerminalUI: anyNamed('usesTerminalUI'),
+            ipv6: false,
           ),
         )..called(1);
 
@@ -150,6 +152,21 @@
       }, overrides: <Type, Generator>{
         FileSystem: () => testFileSystem,
       });
+
+      testUsingContext('exits when ipv6 is specified and debug-port is not', () async {
+        testDeviceManager.addDevice(device);
+
+        final AttachCommand command = AttachCommand();
+        await expectLater(
+          createTestCommandRunner(command).run(<String>['attach', '--ipv6']),
+          throwsToolExit(
+            message: 'When the --debug-port is unknown, this command determines '
+                     'the value of --ipv6 on its own.',
+          ),
+        );
+      }, overrides: <Type, Generator>{
+        FileSystem: () => testFileSystem,
+      },);
     });
 
 
@@ -170,7 +187,8 @@
           target: anyNamed('target'),
           debuggingOptions: anyNamed('debuggingOptions'),
           packagesFilePath: anyNamed('packagesFilePath'),
-          usesTerminalUI: anyNamed('usesTerminalUI'))).thenReturn(
+          usesTerminalUI: anyNamed('usesTerminalUI'),
+          ipv6: false)).thenReturn(
           MockHotRunner());
 
       testDeviceManager.addDevice(device);
@@ -199,33 +217,92 @@
           target: foo.path,
           debuggingOptions: anyNamed('debuggingOptions'),
           packagesFilePath: anyNamed('packagesFilePath'),
-          usesTerminalUI: anyNamed('usesTerminalUI'))).called(1);
+          usesTerminalUI: anyNamed('usesTerminalUI'),
+          ipv6: false)).called(1);
     }, overrides: <Type, Generator>{
       FileSystem: () => testFileSystem,
     },);
 
-    testUsingContext('forwards to given port', () async {
+    group('forwarding to given port', () {
       const int devicePort = 499;
       const int hostPort = 42;
-      final MockPortForwarder portForwarder = MockPortForwarder();
-      final MockAndroidDevice device = MockAndroidDevice();
+      MockPortForwarder portForwarder;
+      MockAndroidDevice device;
 
-      when(device.portForwarder).thenReturn(portForwarder);
-      when(portForwarder.forward(devicePort)).thenAnswer((_) async => hostPort);
-      when(portForwarder.forwardedPorts).thenReturn(
-          <ForwardedPort>[ForwardedPort(hostPort, devicePort)]);
-      when(portForwarder.unforward(any)).thenAnswer((_) async => null);
-      testDeviceManager.addDevice(device);
+      setUp(() {
+        portForwarder = MockPortForwarder();
+        device = MockAndroidDevice();
 
-      final AttachCommand command = AttachCommand();
+        when(device.portForwarder).thenReturn(portForwarder);
+        when(portForwarder.forward(devicePort)).thenAnswer((_) async => hostPort);
+        when(portForwarder.forwardedPorts).thenReturn(
+            <ForwardedPort>[ForwardedPort(hostPort, devicePort)]);
+        when(portForwarder.unforward(any)).thenAnswer((_) async => null);
+      });
 
-      await createTestCommandRunner(command).run(
-          <String>['attach', '--debug-port', '$devicePort']);
+      testUsingContext('succeeds in ipv4 mode', () async {
+        testDeviceManager.addDevice(device);
+        final AttachCommand command = AttachCommand();
 
-      verify(portForwarder.forward(devicePort)).called(1);
-    }, overrides: <Type, Generator>{
-      FileSystem: () => testFileSystem,
-    },);
+        await createTestCommandRunner(command).run(
+            <String>['attach', '--debug-port', '$devicePort']);
+
+        verify(portForwarder.forward(devicePort)).called(1);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => testFileSystem,
+      });
+
+      testUsingContext('succeeds in ipv6 mode', () async {
+        testDeviceManager.addDevice(device);
+        final AttachCommand command = AttachCommand();
+
+        await createTestCommandRunner(command).run(
+            <String>['attach', '--debug-port', '$devicePort', '--ipv6']);
+
+        verify(portForwarder.forward(devicePort)).called(1);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => testFileSystem,
+      });
+
+      testUsingContext('skips in ipv4 mode with a provided observatory port', () async {
+        testDeviceManager.addDevice(device);
+        final AttachCommand command = AttachCommand();
+
+        await createTestCommandRunner(command).run(
+            <String>[
+              'attach',
+              '--debug-port',
+              '$devicePort',
+              '--observatory-port',
+              '$hostPort',
+            ],
+        );
+
+        verifyNever(portForwarder.forward(devicePort));
+      }, overrides: <Type, Generator>{
+        FileSystem: () => testFileSystem,
+      });
+
+      testUsingContext('skips in ipv6 mode with a provided observatory port', () async {
+        testDeviceManager.addDevice(device);
+        final AttachCommand command = AttachCommand();
+
+        await createTestCommandRunner(command).run(
+            <String>[
+              'attach',
+              '--debug-port',
+              '$devicePort',
+              '--observatory-port',
+              '$hostPort',
+              '--ipv6',
+            ],
+        );
+
+        verifyNever(portForwarder.forward(devicePort));
+      }, overrides: <Type, Generator>{
+        FileSystem: () => testFileSystem,
+      });
+    });
 
     testUsingContext('exits when no device connected', () async {
       final AttachCommand command = AttachCommand();