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);
+ }
+}