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/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 new file mode 100644 index 000000000000..bd54789accfb --- /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 _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/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 6126e750f4ca..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: @@ -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..d525153dc0a0 --- /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.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); + } +}