V1.9.1 hotfixes (#41229)

Bump version of just package:multicast_dns (#41207)
Implement mdns for flutter run (#40447)
diff --git a/dev/bots/test.dart b/dev/bots/test.dart
index c546075..aef335f 100644
--- a/dev/bots/test.dart
+++ b/dev/bots/test.dart
@@ -919,7 +919,7 @@
     print('$redLine');
     exit(1);
   }
-  final RegExp pattern = RegExp(r'^\d+\.\d+\.\d+(?:|-pre\.\d+|\+hotfix\.\d+)$');
+  final RegExp pattern = RegExp(r'^\d+\.\d+\.\d+(?:|-pre\.\d+|\+hotfix\.\d+)(-pre\.\d+)?$');
   if (!version.contains(pattern)) {
     print('$redLine');
     print('The version logic generated an invalid version string.');
diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
index dd68a47..20a23a3 100644
--- a/packages/flutter_tools/lib/src/commands/attach.dart
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -4,8 +4,6 @@
 
 import 'dart:async';
 
-import 'package:multicast_dns/multicast_dns.dart';
-
 import '../artifacts.dart';
 import '../base/common.dart';
 import '../base/context.dart';
@@ -20,6 +18,7 @@
 import '../globals.dart';
 import '../ios/devices.dart';
 import '../ios/simulators.dart';
+import '../mdns_discovery.dart';
 import '../project.dart';
 import '../protocol_discovery.dart';
 import '../resident_runner.dart';
@@ -204,7 +203,7 @@
     final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback;
 
     bool attachLogger = false;
-    if (devicePort == null  && debugUri == null) {
+    if (devicePort == null && debugUri == null) {
       if (device is FuchsiaDevice) {
         attachLogger = true;
         final String module = argResults['module'];
@@ -225,10 +224,11 @@
           rethrow;
         }
       } else if ((device is IOSDevice) || (device is IOSSimulator)) {
-        final MDnsObservatoryDiscoveryResult result = await MDnsObservatoryDiscovery().query(applicationId: appId);
-        if (result != null) {
-          observatoryUri = await _buildObservatoryUri(device, hostname, result.port, result.authCode);
-        }
+        observatoryUri = await MDnsObservatoryDiscovery.instance.getObservatoryUri(
+          appId,
+          device,
+          usesIpv6,
+        );
       }
       // If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery.
       if (observatoryUri == null) {
@@ -250,8 +250,13 @@
         }
       }
     } else {
-      observatoryUri = await _buildObservatoryUri(device,
-          debugUri?.host ?? hostname, devicePort ?? debugUri.port, debugUri?.path);
+      observatoryUri = await buildObservatoryUri(
+        device,
+        debugUri?.host ?? hostname,
+        devicePort ?? debugUri.port,
+        observatoryPort,
+        debugUri?.path,
+      );
     }
     try {
       final bool useHot = getBuildInfo().isDebug;
@@ -333,22 +338,6 @@
   }
 
   Future<void> _validateArguments() async { }
-
-  Future<Uri> _buildObservatoryUri(Device device,
-      String host, int devicePort, [String authCode]) async {
-    String path = '/';
-    if (authCode != null) {
-      path = authCode;
-    }
-    // Not having a trailing slash can cause problems in some situations.
-    // Ensure that there's one present.
-    if (!path.endsWith('/')) {
-      path += '/';
-    }
-    final int localPort = observatoryPort
-        ?? await device.portForwarder.forward(devicePort);
-    return Uri(scheme: 'http', host: host, port: localPort, path: path);
-  }
 }
 
 class HotRunnerFactory {
@@ -381,132 +370,3 @@
     ipv6: ipv6,
   );
 }
-
-class MDnsObservatoryDiscoveryResult {
-  MDnsObservatoryDiscoveryResult(this.port, this.authCode);
-  final int port;
-  final String authCode;
-}
-
-/// A wrapper around [MDnsClient] to find a Dart observatory instance.
-class MDnsObservatoryDiscovery {
-  /// Creates a new [MDnsObservatoryDiscovery] object.
-  ///
-  /// The [client] parameter will be defaulted to a new [MDnsClient] if null.
-  /// The [applicationId] parameter may be null, and can be used to
-  /// automatically select which application to use if multiple are advertising
-  /// Dart observatory ports.
-  MDnsObservatoryDiscovery({MDnsClient mdnsClient})
-    : client = mdnsClient ?? MDnsClient();
-
-  /// The [MDnsClient] used to do a lookup.
-  final MDnsClient client;
-
-  static const String dartObservatoryName = '_dartobservatory._tcp.local';
-
-  /// Executes an mDNS query for a Dart Observatory.
-  ///
-  /// The [applicationId] parameter may be used to specify which application
-  /// to find.  For Android, it refers to the package name; on iOS, it refers to
-  /// the bundle ID.
-  ///
-  /// If it is not null, this method will find the port and authentication code
-  /// of the Dart Observatory for that application. If it cannot find a Dart
-  /// Observatory matching that application identifier, it will call
-  /// [throwToolExit].
-  ///
-  /// If it is null and there are multiple ports available, the user will be
-  /// prompted with a list of available observatory ports and asked to select
-  /// one.
-  ///
-  /// If it is null and there is only one available instance of Observatory,
-  /// it will return that instance's information regardless of what application
-  /// the Observatory instance is for.
-  Future<MDnsObservatoryDiscoveryResult> query({String applicationId}) async {
-    printStatus('Checking for advertised Dart observatories...');
-    try {
-      await client.start();
-      final List<PtrResourceRecord> pointerRecords = await client
-          .lookup<PtrResourceRecord>(
-            ResourceRecordQuery.serverPointer(dartObservatoryName),
-          )
-          .toList();
-      if (pointerRecords.isEmpty) {
-        return null;
-      }
-      // We have no guarantee that we won't get multiple hits from the same
-      // service on this.
-      final List<String> uniqueDomainNames = pointerRecords
-          .map<String>((PtrResourceRecord record) => record.domainName)
-          .toSet()
-          .toList();
-
-      String domainName;
-      if (applicationId != null) {
-        for (String name in uniqueDomainNames) {
-          if (name.toLowerCase().startsWith(applicationId.toLowerCase())) {
-            domainName = name;
-            break;
-          }
-        }
-        if (domainName == null) {
-          throwToolExit('Did not find a observatory port advertised for $applicationId.');
-        }
-      } else if (uniqueDomainNames.length > 1) {
-        final StringBuffer buffer = StringBuffer();
-        buffer.writeln('There are multiple observatory ports available.');
-        buffer.writeln('Rerun this command with one of the following passed in as the appId:');
-        buffer.writeln('');
-         for (final String uniqueDomainName in uniqueDomainNames) {
-          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
-        }
-        throwToolExit(buffer.toString());
-      } else {
-        domainName = pointerRecords[0].domainName;
-      }
-      printStatus('Checking for available port on $domainName');
-      // Here, if we get more than one, it should just be a duplicate.
-      final List<SrvResourceRecord> srv = await client
-          .lookup<SrvResourceRecord>(
-            ResourceRecordQuery.service(domainName),
-          )
-          .toList();
-      if (srv.isEmpty) {
-        return null;
-      }
-      if (srv.length > 1) {
-        printError('Unexpectedly found more than one observatory report for $domainName '
-                   '- using first one (${srv.first.port}).');
-      }
-      printStatus('Checking for authentication code for $domainName');
-      final List<TxtResourceRecord> txt = await client
-        .lookup<TxtResourceRecord>(
-            ResourceRecordQuery.text(domainName),
-        )
-        ?.toList();
-      if (txt == null || txt.isEmpty) {
-        return MDnsObservatoryDiscoveryResult(srv.first.port, '');
-      }
-      String authCode = '';
-      const String authCodePrefix = 'authCode=';
-      String raw = txt.first.text;
-      // TXT has a format of [<length byte>, text], so if the length is 2,
-      // that means that TXT is empty.
-      if (raw.length > 2) {
-        // Remove length byte from raw txt.
-        raw = raw.substring(1);
-        if (raw.startsWith(authCodePrefix)) {
-          authCode = raw.substring(authCodePrefix.length);
-          // The Observatory currently expects a trailing '/' as part of the
-          // URI, otherwise an invalid authentication code response is given.
-          if (!authCode.endsWith('/')) {
-            authCode += '/';
-          }
-        }
-      }
-      return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
-    } finally {
-      client.stop();
-    }
-  }
-}
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index 83985e5..1207434 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -42,6 +42,7 @@
 import 'macos/macos_workflow.dart';
 import 'macos/xcode.dart';
 import 'macos/xcode_validator.dart';
+import 'mdns_discovery.dart';
 import 'reporting/reporting.dart';
 import 'run_hot.dart';
 import 'version.dart';
@@ -96,6 +97,7 @@
       LinuxWorkflow: () => const LinuxWorkflow(),
       Logger: () => platform.isWindows ? WindowsStdoutLogger() : StdoutLogger(),
       MacOSWorkflow: () => const MacOSWorkflow(),
+      MDnsObservatoryDiscovery: () => MDnsObservatoryDiscovery(),
       OperatingSystemUtils: () => OperatingSystemUtils(),
       SimControl: () => SimControl(),
       Stdio: () => const Stdio(),
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index 51d8f85..6653c0c 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -18,6 +18,7 @@
 import '../convert.dart';
 import '../device.dart';
 import '../globals.dart';
+import '../mdns_discovery.dart';
 import '../project.dart';
 import '../protocol_discovery.dart';
 import '../reporting/reporting.dart';
@@ -382,16 +383,36 @@
         return LaunchResult.succeeded();
       }
 
+      Uri localUri;
       try {
         printTrace('Application launched on the device. Waiting for observatory port.');
-        final Uri localUri = await observatoryDiscovery.uri;
-        return LaunchResult.succeeded(observatoryUri: localUri);
+        localUri = await MDnsObservatoryDiscovery.instance.getObservatoryUri(
+          package.id,
+          this,
+          ipv6,
+          debuggingOptions.observatoryPort,
+        );
+        if (localUri != null) {
+          return LaunchResult.succeeded(observatoryUri: localUri);
+        }
       } catch (error) {
         printError('Failed to establish a debug connection with $id: $error');
-        return LaunchResult.failed();
+      }
+
+      // Fallback to manual protocol discovery
+      printTrace('mDNS lookup failed, attempting fallback to reading device log.');
+      try {
+        printTrace('Waiting for observatory port.');
+        localUri = await observatoryDiscovery.uri;
+        if (localUri != null) {
+          return LaunchResult.succeeded(observatoryUri: localUri);
+        }
+      } catch (error) {
+        printError('Failed to establish a debug connection with $id: $error');
       } finally {
         await observatoryDiscovery?.cancel();
       }
+      return LaunchResult.failed();
     } finally {
       installStatus.stop();
     }
diff --git a/packages/flutter_tools/lib/src/mdns_discovery.dart b/packages/flutter_tools/lib/src/mdns_discovery.dart
new file mode 100644
index 0000000..72aa60a
--- /dev/null
+++ b/packages/flutter_tools/lib/src/mdns_discovery.dart
@@ -0,0 +1,174 @@
+// Copyright 2019 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:meta/meta.dart';
+import 'package:multicast_dns/multicast_dns.dart';
+
+import 'base/common.dart';
+import 'base/context.dart';
+import 'base/io.dart';
+import 'device.dart';
+import 'globals.dart';
+
+/// A wrapper around [MDnsClient] to find a Dart observatory instance.
+class MDnsObservatoryDiscovery {
+  /// Creates a new [MDnsObservatoryDiscovery] object.
+  ///
+  /// The [client] parameter will be defaulted to a new [MDnsClient] if null.
+  /// The [applicationId] parameter may be null, and can be used to
+  /// automatically select which application to use if multiple are advertising
+  /// Dart observatory ports.
+  MDnsObservatoryDiscovery({MDnsClient mdnsClient})
+    : client = mdnsClient ?? MDnsClient();
+
+  /// The [MDnsClient] used to do a lookup.
+  final MDnsClient client;
+
+  @visibleForTesting
+  static const String dartObservatoryName = '_dartobservatory._tcp.local';
+
+  static MDnsObservatoryDiscovery get instance => context.get<MDnsObservatoryDiscovery>();
+
+  /// Executes an mDNS query for a Dart Observatory.
+  ///
+  /// The [applicationId] parameter may be used to specify which application
+  /// to find.  For Android, it refers to the package name; on iOS, it refers to
+  /// the bundle ID.
+  ///
+  /// If it is not null, this method will find the port and authentication code
+  /// of the Dart Observatory for that application. If it cannot find a Dart
+  /// Observatory matching that application identifier, it will call
+  /// [throwToolExit].
+  ///
+  /// If it is null and there are multiple ports available, the user will be
+  /// prompted with a list of available observatory ports and asked to select
+  /// one.
+  ///
+  /// If it is null and there is only one available instance of Observatory,
+  /// it will return that instance's information regardless of what application
+  /// the Observatory instance is for.
+  Future<MDnsObservatoryDiscoveryResult> query({String applicationId}) async {
+    printStatus('Checking for advertised Dart observatories...');
+    try {
+      await client.start();
+      final List<PtrResourceRecord> pointerRecords = await client
+          .lookup<PtrResourceRecord>(
+            ResourceRecordQuery.serverPointer(dartObservatoryName),
+          )
+          .toList();
+      if (pointerRecords.isEmpty) {
+        return null;
+      }
+      // We have no guarantee that we won't get multiple hits from the same
+      // service on this.
+      final List<String> uniqueDomainNames = pointerRecords
+          .map<String>((PtrResourceRecord record) => record.domainName)
+          .toSet()
+          .toList();
+
+      String domainName;
+      if (applicationId != null) {
+        for (String name in uniqueDomainNames) {
+          if (name.toLowerCase().startsWith(applicationId.toLowerCase())) {
+            domainName = name;
+            break;
+          }
+        }
+        if (domainName == null) {
+          throwToolExit('Did not find a observatory port advertised for $applicationId.');
+        }
+      } else if (uniqueDomainNames.length > 1) {
+        final StringBuffer buffer = StringBuffer();
+        buffer.writeln('There are multiple observatory ports available.');
+        buffer.writeln('Rerun this command with one of the following passed in as the appId:');
+        buffer.writeln('');
+         for (final String uniqueDomainName in uniqueDomainNames) {
+          buffer.writeln('  flutter attach --app-id ${uniqueDomainName.replaceAll('.$dartObservatoryName', '')}');
+        }
+        throwToolExit(buffer.toString());
+      } else {
+        domainName = pointerRecords[0].domainName;
+      }
+      printStatus('Checking for available port on $domainName');
+      // Here, if we get more than one, it should just be a duplicate.
+      final List<SrvResourceRecord> srv = await client
+          .lookup<SrvResourceRecord>(
+            ResourceRecordQuery.service(domainName),
+          )
+          .toList();
+      if (srv.isEmpty) {
+        return null;
+      }
+      if (srv.length > 1) {
+        printError('Unexpectedly found more than one observatory report for $domainName '
+                   '- using first one (${srv.first.port}).');
+      }
+      printStatus('Checking for authentication code for $domainName');
+      final List<TxtResourceRecord> txt = await client
+        .lookup<TxtResourceRecord>(
+            ResourceRecordQuery.text(domainName),
+        )
+        ?.toList();
+      if (txt == null || txt.isEmpty) {
+        return MDnsObservatoryDiscoveryResult(srv.first.port, '');
+      }
+      String authCode = '';
+      const String authCodePrefix = 'authCode=';
+      String raw = txt.first.text;
+      // TXT has a format of [<length byte>, text], so if the length is 2,
+      // that means that TXT is empty.
+      if (raw.length > 2) {
+        // Remove length byte from raw txt.
+        raw = raw.substring(1);
+        if (raw.startsWith(authCodePrefix)) {
+          authCode = raw.substring(authCodePrefix.length);
+          // The Observatory currently expects a trailing '/' as part of the
+          // URI, otherwise an invalid authentication code response is given.
+          if (!authCode.endsWith('/')) {
+            authCode += '/';
+          }
+        }
+      }
+      return MDnsObservatoryDiscoveryResult(srv.first.port, authCode);
+    } finally {
+      client.stop();
+    }
+  }
+
+  Future<Uri> getObservatoryUri(String applicationId, Device device, [bool usesIpv6 = false, int observatoryPort]) async {
+    final MDnsObservatoryDiscoveryResult result = await query(applicationId: applicationId);
+    Uri observatoryUri;
+    if (result != null) {
+      final String host = usesIpv6
+        ? InternetAddress.loopbackIPv6.address
+        : InternetAddress.loopbackIPv4.address;
+      observatoryUri = await buildObservatoryUri(device, host, result.port, observatoryPort, result.authCode);
+    }
+    return observatoryUri;
+  }
+}
+
+class MDnsObservatoryDiscoveryResult {
+  MDnsObservatoryDiscoveryResult(this.port, this.authCode);
+  final int port;
+  final String authCode;
+}
+
+Future<Uri> buildObservatoryUri(Device device,
+    String host, int devicePort, [int observatoryPort, String authCode]) async {
+  String path = '/';
+  if (authCode != null) {
+    path = authCode;
+  }
+  // Not having a trailing slash can cause problems in some situations.
+  // Ensure that there's one present.
+  if (!path.endsWith('/')) {
+    path += '/';
+  }
+  final int localPort = observatoryPort
+      ?? await device.portForwarder.forward(devicePort);
+  return Uri(scheme: 'http', host: host, port: localPort, path: path);
+}
diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml
index 8184e64..58a2692 100644
--- a/packages/flutter_tools/pubspec.yaml
+++ b/packages/flutter_tools/pubspec.yaml
@@ -21,7 +21,7 @@
   json_rpc_2: 2.1.0
   linter: 0.1.93
   meta: 1.1.7
-  multicast_dns: 0.2.0
+  multicast_dns: 0.2.1
   mustache: 1.1.1
   package_config: 1.0.5
   platform: 2.2.1
@@ -135,4 +135,4 @@
   # Exclude this package from the hosted API docs.
   nodoc: true
 
-# PUBSPEC CHECKSUM: 3394
+# PUBSPEC CHECKSUM: 7d95
diff --git a/packages/flutter_tools/test/general.shard/commands/attach_test.dart b/packages/flutter_tools/test/general.shard/commands/attach_test.dart
index 23a1a47..38cd4b4 100644
--- a/packages/flutter_tools/test/general.shard/commands/attach_test.dart
+++ b/packages/flutter_tools/test/general.shard/commands/attach_test.dart
@@ -13,11 +13,12 @@
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/commands/attach.dart';
 import 'package:flutter_tools/src/device.dart';
+import 'package:flutter_tools/src/ios/devices.dart';
+import 'package:flutter_tools/src/mdns_discovery.dart';
 import 'package:flutter_tools/src/resident_runner.dart';
 import 'package:flutter_tools/src/run_hot.dart';
 import 'package:meta/meta.dart';
 import 'package:mockito/mockito.dart';
-import 'package:multicast_dns/multicast_dns.dart';
 
 import '../../src/common.dart';
 import '../../src/context.dart';
@@ -263,7 +264,7 @@
       when(portForwarder.unforward(any))
         .thenAnswer((_) async => null);
       when(mockHotRunner.attach(appStartedCompleter: anyNamed('appStartedCompleter')))
-          .thenAnswer((_) async => 0);
+        .thenAnswer((_) async => 0);
       when(mockHotRunnerFactory.build(
         any,
         target: anyNamed('target'),
@@ -470,137 +471,13 @@
       FileSystem: () => testFileSystem,
     });
   });
-
-  group('mDNS Discovery', () {
-    final int year3000 = DateTime(3000).millisecondsSinceEpoch;
-
-    MDnsClient getMockClient(
-      List<PtrResourceRecord> ptrRecords,
-      Map<String, List<SrvResourceRecord>> srvResponse,
-    ) {
-      final MDnsClient client = MockMDnsClient();
-
-      when(client.lookup<PtrResourceRecord>(
-        ResourceRecordQuery.serverPointer(MDnsObservatoryDiscovery.dartObservatoryName),
-      )).thenAnswer((_) => Stream<PtrResourceRecord>.fromIterable(ptrRecords));
-
-      for (final MapEntry<String, List<SrvResourceRecord>> entry in srvResponse.entries) {
-        when(client.lookup<SrvResourceRecord>(
-          ResourceRecordQuery.service(entry.key),
-        )).thenAnswer((_) => Stream<SrvResourceRecord>.fromIterable(entry.value));
-      }
-      return client;
-    }
-
-    testUsingContext('No ports available', () async {
-      final MDnsClient client = getMockClient(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      final int port = (await portDiscovery.query())?.port;
-      expect(port, isNull);
-    });
-
-    testUsingContext('One port available, no appId', () async {
-      final MDnsClient client = getMockClient(
-        <PtrResourceRecord>[
-          PtrResourceRecord('foo', year3000, domainName: 'bar'),
-        ],
-        <String, List<SrvResourceRecord>>{
-          'bar': <SrvResourceRecord>[
-            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
-          ],
-        },
-      );
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      final int port = (await portDiscovery.query())?.port;
-      expect(port, 123);
-    });
-
-    testUsingContext('Multiple ports available, without appId', () async {
-      final MDnsClient client = getMockClient(
-        <PtrResourceRecord>[
-          PtrResourceRecord('foo', year3000, domainName: 'bar'),
-          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
-        ],
-        <String, List<SrvResourceRecord>>{
-          'bar': <SrvResourceRecord>[
-            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
-          ],
-          'fiz': <SrvResourceRecord>[
-            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
-          ],
-        },
-      );
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      expect(() => portDiscovery.query(), throwsToolExit());
-    });
-
-    testUsingContext('Multiple ports available, with appId', () async {
-      final MDnsClient client = getMockClient(
-        <PtrResourceRecord>[
-          PtrResourceRecord('foo', year3000, domainName: 'bar'),
-          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
-        ],
-        <String, List<SrvResourceRecord>>{
-          'bar': <SrvResourceRecord>[
-            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
-          ],
-          'fiz': <SrvResourceRecord>[
-            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
-          ],
-        },
-      );
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      final int port = (await portDiscovery.query(applicationId: 'fiz'))?.port;
-      expect(port, 321);
-    });
-
-    testUsingContext('Multiple ports available per process, with appId', () async {
-      final MDnsClient client = getMockClient(
-        <PtrResourceRecord>[
-          PtrResourceRecord('foo', year3000, domainName: 'bar'),
-          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
-        ],
-        <String, List<SrvResourceRecord>>{
-          'bar': <SrvResourceRecord>[
-            SrvResourceRecord('bar', year3000, port: 1234, weight: 1, priority: 1, target: 'appId'),
-            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
-          ],
-          'fiz': <SrvResourceRecord>[
-            SrvResourceRecord('fiz', year3000, port: 4321, weight: 1, priority: 1, target: 'local'),
-            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
-          ],
-        },
-      );
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
-      expect(port, 1234);
-    });
-
-    testUsingContext('Query returns null', () async {
-      final MDnsClient client = getMockClient(
-        <PtrResourceRecord>[],
-         <String, List<SrvResourceRecord>>{},
-      );
-
-      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
-      final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
-      expect(port, isNull);
-    });
-  });
 }
 
-class MockMDnsClient extends Mock implements MDnsClient {}
-
-class MockPortForwarder extends Mock implements DevicePortForwarder {}
-
 class MockHotRunner extends Mock implements HotRunner {}
-
 class MockHotRunnerFactory extends Mock implements HotRunnerFactory {}
+class MockIOSDevice extends Mock implements IOSDevice {}
+class MockMDnsObservatoryDiscovery extends Mock implements MDnsObservatoryDiscovery {}
+class MockPortForwarder extends Mock implements DevicePortForwarder {}
 
 class StreamLogger extends Logger {
   @override
diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
index 3e31c32..dba51cf 100644
--- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -16,6 +16,7 @@
 import 'package:flutter_tools/src/ios/devices.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
 import 'package:flutter_tools/src/macos/xcode.dart';
+import 'package:flutter_tools/src/mdns_discovery.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:mockito/mockito.dart';
 import 'package:platform/platform.dart';
@@ -31,6 +32,7 @@
 class MockDirectory extends Mock implements Directory {}
 class MockFileSystem extends Mock implements FileSystem {}
 class MockIMobileDevice extends Mock implements IMobileDevice {}
+class MockMDnsObservatoryDiscovery extends Mock implements MDnsObservatoryDiscovery {}
 class MockXcode extends Mock implements Xcode {}
 class MockFile extends Mock implements File {}
 class MockPortForwarder extends Mock implements DevicePortForwarder {}
@@ -70,6 +72,7 @@
       MockFileSystem mockFileSystem;
       MockProcessManager mockProcessManager;
       MockDeviceLogReader mockLogReader;
+      MockMDnsObservatoryDiscovery mockMDnsObservatoryDiscovery;
       MockPortForwarder mockPortForwarder;
 
       const int devicePort = 499;
@@ -91,6 +94,7 @@
         mockCache = MockCache();
         when(mockCache.dyLdLibEntry).thenReturn(libraryEntry);
         mockFileSystem = MockFileSystem();
+        mockMDnsObservatoryDiscovery = MockMDnsObservatoryDiscovery();
         mockProcessManager = MockProcessManager();
         mockLogReader = MockDeviceLogReader();
         mockPortForwarder = MockPortForwarder();
@@ -132,16 +136,18 @@
         mockLogReader.dispose();
       });
 
-      testUsingContext(' succeeds in debug mode', () async {
+      testUsingContext(' succeeds in debug mode via mDNS', () async {
         final IOSDevice device = IOSDevice('123');
         device.portForwarder = mockPortForwarder;
         device.setLogReader(mockApp, mockLogReader);
-
-        // 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');
-        });
+        final Uri uri = Uri(
+          scheme: 'http',
+          host: '127.0.0.1',
+          port: 1234,
+          path: 'observatory',
+        );
+        when(mockMDnsObservatoryDiscovery.getObservatoryUri(any, any, any))
+          .thenAnswer((Invocation invocation) => Future<Uri>.value(uri));
 
         final LaunchResult launchResult = await device.startApp(mockApp,
           prebuiltApplication: true,
@@ -155,6 +161,65 @@
         Artifacts: () => mockArtifacts,
         Cache: () => mockCache,
         FileSystem: () => mockFileSystem,
+        MDnsObservatoryDiscovery: () => mockMDnsObservatoryDiscovery,
+        Platform: () => macPlatform,
+        ProcessManager: () => mockProcessManager,
+      });
+
+      testUsingContext(' succeeds in debug mode when mDNS fails by falling back to manual protocol discovery', () async {
+        final IOSDevice device = IOSDevice('123');
+        device.portForwarder = mockPortForwarder;
+        device.setLogReader(mockApp, mockLogReader);
+        // 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');
+        });
+        when(mockMDnsObservatoryDiscovery.getObservatoryUri(any, any, any))
+          .thenAnswer((Invocation invocation) => Future<Uri>.value(null));
+
+        final LaunchResult launchResult = await device.startApp(mockApp,
+          prebuiltApplication: true,
+          debuggingOptions: DebuggingOptions.enabled(const BuildInfo(BuildMode.debug, null)),
+          platformArgs: <String, dynamic>{},
+        );
+        expect(launchResult.started, isTrue);
+        expect(launchResult.hasObservatory, isTrue);
+        expect(await device.stopApp(mockApp), isFalse);
+      }, overrides: <Type, Generator>{
+        Artifacts: () => mockArtifacts,
+        Cache: () => mockCache,
+        FileSystem: () => mockFileSystem,
+        MDnsObservatoryDiscovery: () => mockMDnsObservatoryDiscovery,
+        Platform: () => macPlatform,
+        ProcessManager: () => mockProcessManager,
+      });
+
+      testUsingContext(' fails in debug mode when mDNS fails and when Observatory URI is malformed', () async {
+        final IOSDevice device = IOSDevice('123');
+        device.portForwarder = mockPortForwarder;
+        device.setLogReader(mockApp, mockLogReader);
+
+        // 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');
+        });
+        when(mockMDnsObservatoryDiscovery.getObservatoryUri(any, any, any))
+          .thenAnswer((Invocation invocation) => Future<Uri>.value(null));
+
+        final LaunchResult launchResult = await device.startApp(mockApp,
+            prebuiltApplication: true,
+            debuggingOptions: DebuggingOptions.enabled(const BuildInfo(BuildMode.debug, null)),
+            platformArgs: <String, dynamic>{},
+        );
+        expect(launchResult.started, isFalse);
+        expect(launchResult.hasObservatory, isFalse);
+      }, overrides: <Type, Generator>{
+        Artifacts: () => mockArtifacts,
+        Cache: () => mockCache,
+        FileSystem: () => mockFileSystem,
+        MDnsObservatoryDiscovery: () => mockMDnsObservatoryDiscovery,
         Platform: () => macPlatform,
         ProcessManager: () => mockProcessManager,
       });
@@ -176,32 +241,6 @@
         Platform: () => macPlatform,
         ProcessManager: () => mockProcessManager,
       });
-
-      testUsingContext(' fails in debug mode when Observatory URI is malformed', () async {
-        final IOSDevice device = IOSDevice('123');
-        device.portForwarder = mockPortForwarder;
-        device.setLogReader(mockApp, mockLogReader);
-
-        // 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');
-        });
-
-        final LaunchResult launchResult = await device.startApp(mockApp,
-            prebuiltApplication: true,
-            debuggingOptions: DebuggingOptions.enabled(const BuildInfo(BuildMode.debug, null)),
-            platformArgs: <String, dynamic>{},
-        );
-        expect(launchResult.started, isFalse);
-        expect(launchResult.hasObservatory, isFalse);
-      }, overrides: <Type, Generator>{
-        Artifacts: () => mockArtifacts,
-        Cache: () => mockCache,
-        FileSystem: () => mockFileSystem,
-        Platform: () => macPlatform,
-        ProcessManager: () => mockProcessManager,
-      });
     });
 
     group('Process calls', () {
diff --git a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart
new file mode 100644
index 0000000..a56b12e
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart
@@ -0,0 +1,138 @@
+// Copyright 2019 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/mdns_discovery.dart';
+import 'package:mockito/mockito.dart';
+import 'package:multicast_dns/multicast_dns.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+
+void main() {
+  group('mDNS Discovery', () {
+    final int year3000 = DateTime(3000).millisecondsSinceEpoch;
+
+    MDnsClient getMockClient(
+      List<PtrResourceRecord> ptrRecords,
+      Map<String, List<SrvResourceRecord>> srvResponse,
+    ) {
+      final MDnsClient client = MockMDnsClient();
+
+      when(client.lookup<PtrResourceRecord>(
+        ResourceRecordQuery.serverPointer(MDnsObservatoryDiscovery.dartObservatoryName),
+      )).thenAnswer((_) => Stream<PtrResourceRecord>.fromIterable(ptrRecords));
+
+      for (final MapEntry<String, List<SrvResourceRecord>> entry in srvResponse.entries) {
+        when(client.lookup<SrvResourceRecord>(
+          ResourceRecordQuery.service(entry.key),
+        )).thenAnswer((_) => Stream<SrvResourceRecord>.fromIterable(entry.value));
+      }
+      return client;
+    }
+
+    testUsingContext('No ports available', () async {
+      final MDnsClient client = getMockClient(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      final int port = (await portDiscovery.query())?.port;
+      expect(port, isNull);
+    });
+
+    testUsingContext('One port available, no appId', () async {
+      final MDnsClient client = getMockClient(
+        <PtrResourceRecord>[
+          PtrResourceRecord('foo', year3000, domainName: 'bar'),
+        ],
+        <String, List<SrvResourceRecord>>{
+          'bar': <SrvResourceRecord>[
+            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
+          ],
+        },
+      );
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      final int port = (await portDiscovery.query())?.port;
+      expect(port, 123);
+    });
+
+    testUsingContext('Multiple ports available, without appId', () async {
+      final MDnsClient client = getMockClient(
+        <PtrResourceRecord>[
+          PtrResourceRecord('foo', year3000, domainName: 'bar'),
+          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
+        ],
+        <String, List<SrvResourceRecord>>{
+          'bar': <SrvResourceRecord>[
+            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
+          ],
+          'fiz': <SrvResourceRecord>[
+            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
+          ],
+        },
+      );
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      expect(() => portDiscovery.query(), throwsToolExit());
+    });
+
+    testUsingContext('Multiple ports available, with appId', () async {
+      final MDnsClient client = getMockClient(
+        <PtrResourceRecord>[
+          PtrResourceRecord('foo', year3000, domainName: 'bar'),
+          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
+        ],
+        <String, List<SrvResourceRecord>>{
+          'bar': <SrvResourceRecord>[
+            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
+          ],
+          'fiz': <SrvResourceRecord>[
+            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
+          ],
+        },
+      );
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      final int port = (await portDiscovery.query(applicationId: 'fiz'))?.port;
+      expect(port, 321);
+    });
+
+    testUsingContext('Multiple ports available per process, with appId', () async {
+      final MDnsClient client = getMockClient(
+        <PtrResourceRecord>[
+          PtrResourceRecord('foo', year3000, domainName: 'bar'),
+          PtrResourceRecord('baz', year3000, domainName: 'fiz'),
+        ],
+        <String, List<SrvResourceRecord>>{
+          'bar': <SrvResourceRecord>[
+            SrvResourceRecord('bar', year3000, port: 1234, weight: 1, priority: 1, target: 'appId'),
+            SrvResourceRecord('bar', year3000, port: 123, weight: 1, priority: 1, target: 'appId'),
+          ],
+          'fiz': <SrvResourceRecord>[
+            SrvResourceRecord('fiz', year3000, port: 4321, weight: 1, priority: 1, target: 'local'),
+            SrvResourceRecord('fiz', year3000, port: 321, weight: 1, priority: 1, target: 'local'),
+          ],
+        },
+      );
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
+      expect(port, 1234);
+    });
+
+    testUsingContext('Query returns null', () async {
+      final MDnsClient client = getMockClient(
+        <PtrResourceRecord>[],
+         <String, List<SrvResourceRecord>>{},
+      );
+
+      final MDnsObservatoryDiscovery portDiscovery = MDnsObservatoryDiscovery(mdnsClient: client);
+      final int port = (await portDiscovery.query(applicationId: 'bar'))?.port;
+      expect(port, isNull);
+    });
+  });
+}
+
+class MockMDnsClient extends Mock implements MDnsClient {}