Implement Link for native platforms (#3177)

diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md
index cfee53d..995d64c 100644
--- a/packages/url_launcher/url_launcher/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 5.7.7
+
+* Introduce the Link widget with an implementation for native platforms.
+
 ## 5.7.6
 
 * Suppress deprecation warning on the `shouldOverrideUrlLoading` method on Android of the `FlutterWebChromeClient` class.
diff --git a/packages/url_launcher/url_launcher/lib/link.dart b/packages/url_launcher/url_launcher/lib/link.dart
new file mode 100644
index 0000000..ac1d406
--- /dev/null
+++ b/packages/url_launcher/url_launcher/lib/link.dart
@@ -0,0 +1,7 @@
+// Copyright 2017 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.
+
+export 'src/link.dart' show Link;
+export 'package:url_launcher_platform_interface/link.dart'
+    show FollowLink, LinkTarget, LinkWidgetBuilder;
diff --git a/packages/url_launcher/url_launcher/lib/src/link.dart b/packages/url_launcher/url_launcher/lib/src/link.dart
new file mode 100644
index 0000000..bd54789
--- /dev/null
+++ b/packages/url_launcher/url_launcher/lib/src/link.dart
@@ -0,0 +1,132 @@
+// Copyright 2017 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/widgets.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:url_launcher_platform_interface/link.dart';
+import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
+
+/// A widget that renders a real link on the web, and uses WebViews in native
+/// platforms to open links.
+///
+/// Example link to an external URL:
+///
+/// ```dart
+/// Link(
+///   uri: Uri.parse('https://flutter.dev'),
+///   builder: (BuildContext context, FollowLink followLink) => RaisedButton(
+///     onPressed: followLink,
+///     // ... other properties here ...
+///   )},
+/// );
+/// ```
+///
+/// Example link to a route name within the app:
+///
+/// ```dart
+/// Link(
+///   uri: Uri.parse('/home'),
+///   builder: (BuildContext context, FollowLink followLink) => RaisedButton(
+///     onPressed: followLink,
+///     // ... other properties here ...
+///   )},
+/// );
+/// ```
+class Link extends StatelessWidget implements LinkInfo {
+  /// Called at build time to construct the widget tree under the link.
+  final LinkWidgetBuilder builder;
+
+  /// The destination that this link leads to.
+  final Uri uri;
+
+  /// The target indicating where to open the link.
+  final LinkTarget target;
+
+  /// Whether the link is disabled or not.
+  bool get isDisabled => uri == null;
+
+  /// Creates a widget that renders a real link on the web, and uses WebViews in
+  /// native platforms to open links.
+  Link({
+    Key key,
+    @required this.uri,
+    LinkTarget target,
+    @required this.builder,
+  })  : target = target ?? LinkTarget.defaultTarget,
+        super(key: key);
+
+  LinkDelegate get _effectiveDelegate {
+    return UrlLauncherPlatform.instance.linkDelegate ??
+        DefaultLinkDelegate.create;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return _effectiveDelegate(this);
+  }
+}
+
+/// The default delegate used on non-web platforms.
+///
+/// For external URIs, it uses url_launche APIs. For app route names, it uses
+/// event channel messages to instruct the framework to push the route name.
+class DefaultLinkDelegate extends StatelessWidget {
+  /// Creates a delegate for the given [link].
+  const DefaultLinkDelegate(this.link);
+
+  /// Given a [link], creates an instance of [DefaultLinkDelegate].
+  ///
+  /// This is a static method so it can be used as a tear-off.
+  static DefaultLinkDelegate create(LinkInfo link) {
+    return DefaultLinkDelegate(link);
+  }
+
+  /// Information about the link built by the app.
+  final LinkInfo link;
+
+  bool get _useWebView {
+    if (link.target == LinkTarget.self) return true;
+    if (link.target == LinkTarget.blank) return false;
+    return null;
+  }
+
+  Future<void> _followLink(BuildContext context) async {
+    if (!link.uri.hasScheme) {
+      // A uri that doesn't have a scheme is an internal route name. In this
+      // case, we push it via Flutter's navigation system instead of letting the
+      // browser handle it.
+      final String routeName = link.uri.toString();
+      return pushRouteNameToFramework(context, routeName);
+    }
+
+    // At this point, we know that the link is external. So we use the `launch`
+    // API to open the link.
+    final String urlString = link.uri.toString();
+    if (await canLaunch(urlString)) {
+      await launch(
+        urlString,
+        forceSafariVC: _useWebView,
+        forceWebView: _useWebView,
+      );
+    } else {
+      FlutterError.reportError(FlutterErrorDetails(
+        exception: 'Could not launch link $urlString',
+        stack: StackTrace.current,
+        library: 'url_launcher',
+        context: ErrorDescription('during launching a link'),
+      ));
+    }
+    return Future<void>.value(null);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return link.builder(
+      context,
+      link.isDisabled ? null : () => _followLink(context),
+    );
+  }
+}
diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml
index 6126e75..cf837b2 100644
--- a/packages/url_launcher/url_launcher/pubspec.yaml
+++ b/packages/url_launcher/url_launcher/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Flutter plugin for launching a URL on Android and iOS. Supports
   web, phone, SMS, and email schemes.
 homepage: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher
-version: 5.7.6
+version: 5.7.7
 
 flutter:
   plugin:
@@ -24,13 +24,13 @@
 dependencies:
   flutter:
     sdk: flutter
-  url_launcher_platform_interface: ^1.0.8
+  url_launcher_platform_interface: ^1.0.9
   # The design on https://flutter.dev/go/federated-plugins was to leave
   # this constraint as "any". We cannot do it right now as it fails pub publish
   # validation, so we set a ^ constraint.
   # TODO(amirh): Revisit this (either update this part in the  design or the pub tool).
   # https://github.com/flutter/flutter/issues/46264
-  url_launcher_web: ^0.1.3
+  url_launcher_web: ^0.1.5
   url_launcher_linux: ^0.0.1
   url_launcher_macos: ^0.0.1
   url_launcher_windows: ^0.0.1
diff --git a/packages/url_launcher/url_launcher/test/link_test.dart b/packages/url_launcher/url_launcher/test/link_test.dart
new file mode 100644
index 0000000..d525153
--- /dev/null
+++ b/packages/url_launcher/url_launcher/test/link_test.dart
@@ -0,0 +1,272 @@
+// Copyright 2017 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.
+
+// @dart = 2.8
+
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+import 'package:flutter/services.dart';
+import 'package:plugin_platform_interface/plugin_platform_interface.dart';
+import 'package:url_launcher/link.dart';
+import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
+
+final MethodCodec _codec = const JSONMethodCodec();
+
+void main() {
+  final MockUrlLauncher mock = MockUrlLauncher();
+  UrlLauncherPlatform.instance = mock;
+
+  PlatformMessageCallback realOnPlatformMessage;
+  setUp(() {
+    realOnPlatformMessage = window.onPlatformMessage;
+  });
+  tearDown(() {
+    window.onPlatformMessage = realOnPlatformMessage;
+  });
+
+  group('$Link', () {
+    testWidgets('handles null uri correctly', (WidgetTester tester) async {
+      bool isBuilt = false;
+      FollowLink followLink;
+
+      final Link link = Link(
+        uri: null,
+        builder: (BuildContext context, FollowLink followLink2) {
+          isBuilt = true;
+          followLink = followLink2;
+          return Container();
+        },
+      );
+      await tester.pumpWidget(link);
+
+      expect(link.isDisabled, isTrue);
+      expect(isBuilt, isTrue);
+      expect(followLink, isNull);
+    });
+
+    testWidgets('calls url_launcher for external URLs with blank target',
+        (WidgetTester tester) async {
+      FollowLink followLink;
+
+      await tester.pumpWidget(Link(
+        uri: Uri.parse('http://example.com/foobar'),
+        target: LinkTarget.blank,
+        builder: (BuildContext context, FollowLink followLink2) {
+          followLink = followLink2;
+          return Container();
+        },
+      ));
+
+      when(mock.canLaunch('http://example.com/foobar'))
+          .thenAnswer((realInvocation) => Future<bool>.value(true));
+      clearInteractions(mock);
+      await followLink();
+
+      verifyInOrder([
+        mock.canLaunch('http://example.com/foobar'),
+        mock.launch(
+          'http://example.com/foobar',
+          useSafariVC: false,
+          useWebView: false,
+          universalLinksOnly: false,
+          enableJavaScript: false,
+          enableDomStorage: false,
+          headers: <String, String>{},
+        )
+      ]);
+    });
+
+    testWidgets('calls url_launcher for external URLs with self target',
+        (WidgetTester tester) async {
+      FollowLink followLink;
+
+      await tester.pumpWidget(Link(
+        uri: Uri.parse('http://example.com/foobar'),
+        target: LinkTarget.self,
+        builder: (BuildContext context, FollowLink followLink2) {
+          followLink = followLink2;
+          return Container();
+        },
+      ));
+
+      when(mock.canLaunch('http://example.com/foobar'))
+          .thenAnswer((realInvocation) => Future<bool>.value(true));
+      clearInteractions(mock);
+      await followLink();
+
+      verifyInOrder([
+        mock.canLaunch('http://example.com/foobar'),
+        mock.launch(
+          'http://example.com/foobar',
+          useSafariVC: true,
+          useWebView: true,
+          universalLinksOnly: false,
+          enableJavaScript: false,
+          enableDomStorage: false,
+          headers: <String, String>{},
+        )
+      ]);
+    });
+
+    testWidgets('sends navigation platform messages for internal route names',
+        (WidgetTester tester) async {
+      // Intercept messages sent to the engine.
+      final List<MethodCall> engineCalls = <MethodCall>[];
+      SystemChannels.navigation.setMockMethodCallHandler((MethodCall call) {
+        engineCalls.add(call);
+        return Future<void>.value();
+      });
+
+      // Intercept messages sent to the framework.
+      final List<MethodCall> frameworkCalls = <MethodCall>[];
+      window.onPlatformMessage = (
+        String name,
+        ByteData data,
+        PlatformMessageResponseCallback callback,
+      ) {
+        frameworkCalls.add(_codec.decodeMethodCall(data));
+        realOnPlatformMessage(name, data, callback);
+      };
+
+      final Uri uri = Uri.parse('/foo/bar');
+      FollowLink followLink;
+
+      await tester.pumpWidget(MaterialApp(
+        routes: <String, WidgetBuilder>{
+          '/': (BuildContext context) => Link(
+                uri: uri,
+                builder: (BuildContext context, FollowLink followLink2) {
+                  followLink = followLink2;
+                  return Container();
+                },
+              ),
+          '/foo/bar': (BuildContext context) => Container(),
+        },
+      ));
+
+      engineCalls.clear();
+      frameworkCalls.clear();
+      clearInteractions(mock);
+      await followLink();
+
+      // Shouldn't use url_launcher when uri is an internal route name.
+      verifyZeroInteractions(mock);
+
+      // A message should've been sent to the engine (by the Navigator, not by
+      // the Link widget).
+      //
+      // Even though this message isn't being sent by Link, we still want to
+      // have a test for it because we rely on it for Link to work correctly.
+      expect(engineCalls, hasLength(1));
+      expect(
+        engineCalls.single,
+        isMethodCall('routeUpdated', arguments: <dynamic, dynamic>{
+          'previousRouteName': '/',
+          'routeName': '/foo/bar',
+        }),
+      );
+
+      // Pushes route to the framework.
+      expect(frameworkCalls, hasLength(1));
+      expect(
+        frameworkCalls.single,
+        isMethodCall('pushRoute', arguments: '/foo/bar'),
+      );
+    });
+
+    testWidgets('sends router platform messages for internal route names',
+        (WidgetTester tester) async {
+      // Intercept messages sent to the engine.
+      final List<MethodCall> engineCalls = <MethodCall>[];
+      SystemChannels.navigation.setMockMethodCallHandler((MethodCall call) {
+        engineCalls.add(call);
+        return Future<void>.value();
+      });
+
+      // Intercept messages sent to the framework.
+      final List<MethodCall> frameworkCalls = <MethodCall>[];
+      window.onPlatformMessage = (
+        String name,
+        ByteData data,
+        PlatformMessageResponseCallback callback,
+      ) {
+        frameworkCalls.add(_codec.decodeMethodCall(data));
+        realOnPlatformMessage(name, data, callback);
+      };
+
+      final Uri uri = Uri.parse('/foo/bar');
+      FollowLink followLink;
+
+      final Link link = Link(
+        uri: uri,
+        builder: (BuildContext context, FollowLink followLink2) {
+          followLink = followLink2;
+          return Container();
+        },
+      );
+      await tester.pumpWidget(MaterialApp.router(
+        routeInformationParser: MockRouteInformationParser(),
+        routerDelegate: MockRouterDelegate(
+          builder: (BuildContext context) => link,
+        ),
+      ));
+
+      engineCalls.clear();
+      frameworkCalls.clear();
+      clearInteractions(mock);
+      await followLink();
+
+      // Shouldn't use url_launcher when uri is an internal route name.
+      verifyZeroInteractions(mock);
+
+      // Sends route information update to the engine.
+      expect(engineCalls, hasLength(1));
+      expect(
+        engineCalls.single,
+        isMethodCall('routeInformationUpdated', arguments: <dynamic, dynamic>{
+          'location': '/foo/bar',
+          'state': null
+        }),
+      );
+
+      // Also pushes route information update to the Router.
+      expect(frameworkCalls, hasLength(1));
+      expect(
+        frameworkCalls.single,
+        isMethodCall(
+          'pushRouteInformation',
+          arguments: <dynamic, dynamic>{
+            'location': '/foo/bar',
+            'state': null,
+          },
+        ),
+      );
+    });
+  });
+}
+
+class MockUrlLauncher extends Mock
+    with MockPlatformInterfaceMixin
+    implements UrlLauncherPlatform {}
+
+class MockRouteInformationParser extends Mock
+    implements RouteInformationParser<bool> {
+  @override
+  Future<bool> parseRouteInformation(RouteInformation routeInformation) {
+    return Future<bool>.value(true);
+  }
+}
+
+class MockRouterDelegate extends Mock implements RouterDelegate {
+  MockRouterDelegate({@required this.builder});
+
+  final WidgetBuilder builder;
+
+  @override
+  Widget build(BuildContext context) {
+    return builder(context);
+  }
+}