From 8c48fa72a182e9d41b545c3b600712f48ed96f1e Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 14 Oct 2020 16:03:06 -0700 Subject: [PATCH 1/5] Implement Link for native platforms --- .../url_launcher/lib/src/link.dart | 131 +++++++++ .../url_launcher/lib/url_launcher.dart | 4 + .../url_launcher/url_launcher/pubspec.yaml | 4 +- .../url_launcher/test/link_test.dart | 272 ++++++++++++++++++ 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 packages/url_launcher/url_launcher/lib/src/link.dart create mode 100644 packages/url_launcher/url_launcher/test/link_test.dart 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 000000000000..9f564d09c1e4 --- /dev/null +++ b/packages/url_launcher/url_launcher/lib/src/link.dart @@ -0,0 +1,131 @@ +// 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'; + +export 'package:url_launcher_platform_interface/link.dart' + show LinkTarget, LinkWidgetBuilder, FollowLink; + +Widget defaultLinkDelegate(LinkInfo link) { + return DefaultLinkDelegate(link); +} + +/// 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; + } + + @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); + + /// 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 _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.value(null); + } + + @override + Widget build(BuildContext context) { + return link.builder( + context, + link.isDisabled ? null : () => _followLink(context), + ); + } +} diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index 25aa623a590f..999fd2942bb4 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -9,6 +9,10 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +export 'src/link.dart' show Link; +export 'package:url_launcher_platform_interface/link.dart' + show FollowLink, LinkTarget, LinkWidgetBuilder; + /// Parses the specified URL string and delegates handling of it to the /// underlying platform. /// diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 6126e750f4ca..d85b73d4316f 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -24,13 +24,13 @@ flutter: 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 000000000000..cf66e15526d2 --- /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/url_launcher.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.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: {}, + ) + ]); + }); + + 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.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: {}, + ) + ]); + }); + + testWidgets('sends navigation platform messages for internal route names', + (WidgetTester tester) async { + // Intercept messages sent to the engine. + final List engineCalls = []; + SystemChannels.navigation.setMockMethodCallHandler((MethodCall call) { + engineCalls.add(call); + return Future.value(); + }); + + // Intercept messages sent to the framework. + final List frameworkCalls = []; + 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: { + '/': (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: { + '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 engineCalls = []; + SystemChannels.navigation.setMockMethodCallHandler((MethodCall call) { + engineCalls.add(call); + return Future.value(); + }); + + // Intercept messages sent to the framework. + final List frameworkCalls = []; + 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: { + 'location': '/foo/bar', + 'state': null + }), + ); + + // Also pushes route information update to the Router. + expect(frameworkCalls, hasLength(1)); + expect( + frameworkCalls.single, + isMethodCall( + 'pushRouteInformation', + arguments: { + 'location': '/foo/bar', + 'state': null, + }, + ), + ); + }); + }); +} + +class MockUrlLauncher extends Mock + with MockPlatformInterfaceMixin + implements UrlLauncherPlatform {} + +class MockRouteInformationParser extends Mock + implements RouteInformationParser { + @override + Future parseRouteInformation(RouteInformation routeInformation) { + return Future.value(true); + } +} + +class MockRouterDelegate extends Mock implements RouterDelegate { + MockRouterDelegate({@required this.builder}); + + final WidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return builder(context); + } +} From 407c9c8b6e8ad5fa905ad62e7498d2ea3b8e6357 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 20 Oct 2020 23:14:50 -0700 Subject: [PATCH 2/5] update change log --- packages/url_launcher/url_launcher/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index cfee53d848c3..995d64c441dd 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/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index d85b73d4316f..cf837b24f29e 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher 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: From 6a82fa9f38344311563b5c5f30d640b1a5cc00a1 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 21 Oct 2020 10:00:43 -0700 Subject: [PATCH 3/5] fix analyzer --- .../url_launcher/url_launcher/lib/src/link.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/url_launcher/url_launcher/lib/src/link.dart b/packages/url_launcher/url_launcher/lib/src/link.dart index 9f564d09c1e4..2c16bf696936 100644 --- a/packages/url_launcher/url_launcher/lib/src/link.dart +++ b/packages/url_launcher/url_launcher/lib/src/link.dart @@ -12,10 +12,6 @@ import 'package:url_launcher_platform_interface/url_launcher_platform_interface. export 'package:url_launcher_platform_interface/link.dart' show LinkTarget, LinkWidgetBuilder, FollowLink; -Widget defaultLinkDelegate(LinkInfo link) { - return DefaultLinkDelegate(link); -} - /// A widget that renders a real link on the web, and uses WebViews in native /// platforms to open links. /// @@ -66,7 +62,7 @@ class Link extends StatelessWidget implements LinkInfo { super(key: key); LinkDelegate get _effectiveDelegate { - return UrlLauncherPlatform.instance.linkDelegate ?? defaultLinkDelegate; + return UrlLauncherPlatform.instance.linkDelegate ?? DefaultLinkDelegate.create; } @override @@ -83,6 +79,13 @@ 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; From 364a9cdfd4b2dfc389e93c9734a413ab4f705d60 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 21 Oct 2020 10:19:36 -0700 Subject: [PATCH 4/5] format --- packages/url_launcher/url_launcher/lib/src/link.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/url_launcher/url_launcher/lib/src/link.dart b/packages/url_launcher/url_launcher/lib/src/link.dart index 2c16bf696936..9eefd2556103 100644 --- a/packages/url_launcher/url_launcher/lib/src/link.dart +++ b/packages/url_launcher/url_launcher/lib/src/link.dart @@ -62,7 +62,8 @@ class Link extends StatelessWidget implements LinkInfo { super(key: key); LinkDelegate get _effectiveDelegate { - return UrlLauncherPlatform.instance.linkDelegate ?? DefaultLinkDelegate.create; + return UrlLauncherPlatform.instance.linkDelegate ?? + DefaultLinkDelegate.create; } @override From 99b3a90ed2de0cad22240f5ef438a19c497ddefc Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 21 Oct 2020 16:21:44 -0700 Subject: [PATCH 5/5] move exports around --- packages/url_launcher/url_launcher/lib/link.dart | 7 +++++++ packages/url_launcher/url_launcher/lib/src/link.dart | 3 --- packages/url_launcher/url_launcher/lib/url_launcher.dart | 4 ---- packages/url_launcher/url_launcher/test/link_test.dart | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 packages/url_launcher/url_launcher/lib/link.dart 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 000000000000..ac1d4064d10f --- /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 index 9eefd2556103..bd54789accfb 100644 --- a/packages/url_launcher/url_launcher/lib/src/link.dart +++ b/packages/url_launcher/url_launcher/lib/src/link.dart @@ -9,9 +9,6 @@ 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'; -export 'package:url_launcher_platform_interface/link.dart' - show LinkTarget, LinkWidgetBuilder, FollowLink; - /// A widget that renders a real link on the web, and uses WebViews in native /// platforms to open links. /// diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index 999fd2942bb4..25aa623a590f 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -9,10 +9,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -export 'src/link.dart' show Link; -export 'package:url_launcher_platform_interface/link.dart' - show FollowLink, LinkTarget, LinkWidgetBuilder; - /// Parses the specified URL string and delegates handling of it to the /// underlying platform. /// diff --git a/packages/url_launcher/url_launcher/test/link_test.dart b/packages/url_launcher/url_launcher/test/link_test.dart index cf66e15526d2..d525153dc0a0 100644 --- a/packages/url_launcher/url_launcher/test/link_test.dart +++ b/packages/url_launcher/url_launcher/test/link_test.dart @@ -10,7 +10,7 @@ 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/url_launcher.dart'; +import 'package:url_launcher/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; final MethodCodec _codec = const JSONMethodCodec();