From c6821f970244ddf82d78b492bb61f668297d4a59 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 24 Oct 2023 12:05:01 -0700 Subject: [PATCH] [url_launcher] Add an `inAppBrowserView` mode (#5155) `url_launcher_android` recently switched from an in-app webview to an Android Custom Tab (when supported), which was intended to be an in-place upgrade. However, this broke `closeInAppWebView`, and that couldn't be fixed directly because Android Custom Tab has no mechanism for programatic close. To address the regression, this adds a new `inAppBrowserView` launch mode which is distinct from `inAppWebView`, so that use cases that require programatic close can specifically request `inAppWebView` instead. The default for web links is the new `inAppBrowserView` since that gives better results in most cases. Since whether `closeInAppWebView` will work in any given case is now non-trivial (on iOS, both in-app modes are supported, but on Android it's only the web view mode), this adds a new support API to query it, in keeping with the relatively new guidance of https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#api-support-queries. It also adds API to query for support for being able to use specific launch modes, since there wasn't a good way to understand which modes worked in general on different platforms. Since there are new APIs, this adds support for those APIs to all of our implementations to ensure that they give accurate responses. Fixes https://github.com/flutter/flutter/issues/134208 --- .../url_launcher/url_launcher/CHANGELOG.md | 15 ++++++ packages/url_launcher/url_launcher/README.md | 18 +++++-- .../android/app/src/main/AndroidManifest.xml | 4 ++ .../url_launcher/example/lib/main.dart | 18 ++++--- .../url_launcher/example/pubspec.yaml | 4 +- .../url_launcher/lib/src/link.dart | 2 +- .../url_launcher/lib/src/type_conversion.dart | 2 + .../url_launcher/lib/src/types.dart | 5 +- .../lib/src/url_launcher_string.dart | 3 +- .../lib/src/url_launcher_uri.dart | 51 +++++++++++-------- .../url_launcher/url_launcher/pubspec.yaml | 20 ++++---- .../url_launcher/test/link_test.dart | 2 +- .../mocks/mock_url_launcher_platform.dart | 12 +++++ .../test/src/url_launcher_uri_test.dart | 36 +++++++++++++ 14 files changed, 145 insertions(+), 47 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index cebc817729..0f3c087d8f 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,18 @@ +## 6.2.0 + +* Adds `supportsLaunchMode` for checking whether the current platform supports a + given launch mode, to allow clients that will only work with specific modes + to avoid fallback to a different mode. +* Adds `supportsCloseForLaunchMode` to allow checking programatically if a + launched URL will be able to be closed. Previously the documented behvaior was + that it worked only with the `inAppWebView` launch mode, but this is no longer + true on all platforms with the addition of `inAppBrowserView`. +* Updates the documention for `launchUrl` to clarify that clients should not + rely on any specific behavior of the `platformDefault` launch mode. Changes + to the handling of `platformDefault`, such as Android's recent change from + `inAppWebView` to the new `inAppBrowserView`, are not considered breaking. +* Updates minimum supported SDK version to Flutter 3.13. + ## 6.1.14 * Updates documentation to mention support for Android Custom Tabs. diff --git a/packages/url_launcher/url_launcher/README.md b/packages/url_launcher/url_launcher/README.md index afd60143a5..1eeee6da4f 100644 --- a/packages/url_launcher/url_launcher/README.md +++ b/packages/url_launcher/url_launcher/README.md @@ -66,7 +66,9 @@ See [`-[UIApplication canOpenURL:]`](https://developer.apple.com/documentation/u Add any URL schemes passed to `canLaunchUrl` as `` entries in your `AndroidManifest.xml`, otherwise it will return false in most cases starting -on Android 11 (API 30) or higher. A `` +on Android 11 (API 30) or higher. Checking for +`supportsLaunchMode(PreferredLaunchMode.inAppBrowserView)` also requires +a `` entry to return anything but false. A `` element must be added to your manifest as a child of the root element. Example: @@ -85,6 +87,10 @@ Example: + + + + ``` @@ -210,10 +216,16 @@ if (!await launchUrl(uri)) { If you need to access files outside of your application's sandbox, you will need to have the necessary [entitlements](https://docs.flutter.dev/desktop#entitlements-and-the-app-sandbox). -## Browser vs in-app Handling +## Browser vs in-app handling On some platforms, web URLs can be launched either in an in-app web view, or in the default browser. The default behavior depends on the platform (see [`launchUrl`](https://pub.dev/documentation/url_launcher/latest/url_launcher/launchUrl.html) for details), but a specific mode can be used on supported platforms by -passing a `LaunchMode`. +passing a `PreferredLaunchMode`. + +Platforms that do no support a requested `PreferredLaunchMode` will +automatically fall back to a supported mode (usually `platformDefault`). If +your application needs to avoid that fallback behavior, however, you can check +if the current platform supports a given mode with `supportsLaunchMode` before +calling `launchUrl`. diff --git a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml index fe01f2fba9..5cfc758837 100644 --- a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml +++ b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml @@ -20,6 +20,10 @@ + + + + diff --git a/packages/url_launcher/url_launcher/example/lib/main.dart b/packages/url_launcher/url_launcher/example/lib/main.dart index 57a0ce9ef4..40307a3f71 100644 --- a/packages/url_launcher/url_launcher/example/lib/main.dart +++ b/packages/url_launcher/url_launcher/example/lib/main.dart @@ -62,7 +62,13 @@ class _MyHomePageState extends State { } } - Future _launchInWebViewOrVC(Uri url) async { + Future _launchInBrowserView(Uri url) async { + if (!await launchUrl(url, mode: LaunchMode.inAppBrowserView)) { + throw Exception('Could not launch $url'); + } + } + + Future _launchInWebView(Uri url) async { if (!await launchUrl(url, mode: LaunchMode.inAppWebView)) { throw Exception('Could not launch $url'); } @@ -99,7 +105,7 @@ class _MyHomePageState extends State { } } - Future _launchUniversalLinkIos(Uri url) async { + Future _launchUniversalLinkIOS(Uri url) async { final bool nativeAppLaunchSucceeded = await launchUrl( url, mode: LaunchMode.externalNonBrowserApplication, @@ -107,7 +113,7 @@ class _MyHomePageState extends State { if (!nativeAppLaunchSucceeded) { await launchUrl( url, - mode: LaunchMode.inAppWebView, + mode: LaunchMode.inAppBrowserView, ); } } @@ -173,7 +179,7 @@ class _MyHomePageState extends State { const Padding(padding: EdgeInsets.all(16.0)), ElevatedButton( onPressed: () => setState(() { - _launched = _launchInWebViewOrVC(toLaunch); + _launched = _launchInBrowserView(toLaunch); }), child: const Text('Launch in app'), ), @@ -198,7 +204,7 @@ class _MyHomePageState extends State { const Padding(padding: EdgeInsets.all(16.0)), ElevatedButton( onPressed: () => setState(() { - _launched = _launchUniversalLinkIos(toLaunch); + _launched = _launchUniversalLinkIOS(toLaunch); }), child: const Text( 'Launch a universal link in a native app, fallback to Safari.(Youtube)'), @@ -206,7 +212,7 @@ class _MyHomePageState extends State { const Padding(padding: EdgeInsets.all(16.0)), ElevatedButton( onPressed: () => setState(() { - _launched = _launchInWebViewOrVC(toLaunch); + _launched = _launchInWebView(toLaunch); Timer(const Duration(seconds: 5), () { closeInAppWebView(); }); diff --git a/packages/url_launcher/url_launcher/example/pubspec.yaml b/packages/url_launcher/url_launcher/example/pubspec.yaml index e09df486b4..18cc9c4504 100644 --- a/packages/url_launcher/url_launcher/example/pubspec.yaml +++ b/packages/url_launcher/url_launcher/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the url_launcher plugin. publish_to: none environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: flutter: diff --git a/packages/url_launcher/url_launcher/lib/src/link.dart b/packages/url_launcher/url_launcher/lib/src/link.dart index cea6845b12..b5c4740309 100644 --- a/packages/url_launcher/url_launcher/lib/src/link.dart +++ b/packages/url_launcher/url_launcher/lib/src/link.dart @@ -126,7 +126,7 @@ class DefaultLinkDelegate extends StatelessWidget { success = await launchUrl( url, mode: _useWebView - ? LaunchMode.inAppWebView + ? LaunchMode.inAppBrowserView : LaunchMode.externalApplication, ); } on PlatformException { diff --git a/packages/url_launcher/url_launcher/lib/src/type_conversion.dart b/packages/url_launcher/url_launcher/lib/src/type_conversion.dart index 970f04dced..3169e25dfa 100644 --- a/packages/url_launcher/url_launcher/lib/src/type_conversion.dart +++ b/packages/url_launcher/url_launcher/lib/src/type_conversion.dart @@ -22,6 +22,8 @@ PreferredLaunchMode convertLaunchMode(LaunchMode mode) { switch (mode) { case LaunchMode.platformDefault: return PreferredLaunchMode.platformDefault; + case LaunchMode.inAppBrowserView: + return PreferredLaunchMode.inAppBrowserView; case LaunchMode.inAppWebView: return PreferredLaunchMode.inAppWebView; case LaunchMode.externalApplication: diff --git a/packages/url_launcher/url_launcher/lib/src/types.dart b/packages/url_launcher/url_launcher/lib/src/types.dart index 359e293ef8..2bf56e1b5d 100644 --- a/packages/url_launcher/url_launcher/lib/src/types.dart +++ b/packages/url_launcher/url_launcher/lib/src/types.dart @@ -14,9 +14,12 @@ enum LaunchMode { /// implementation. platformDefault, - /// Loads the URL in an in-app web view (e.g., Android Custom Tabs, Safari View Controller). + /// Loads the URL in an in-app web view (e.g., Android WebView). inAppWebView, + /// Loads the URL in an in-app web view (e.g., Android Custom Tabs, SFSafariViewController). + inAppBrowserView, + /// Passes the URL to the OS to be handled by another application. externalApplication, diff --git a/packages/url_launcher/url_launcher/lib/src/url_launcher_string.dart b/packages/url_launcher/url_launcher/lib/src/url_launcher_string.dart index 45193ff17c..ca0bcc6109 100644 --- a/packages/url_launcher/url_launcher/lib/src/url_launcher_string.dart +++ b/packages/url_launcher/url_launcher/lib/src/url_launcher_string.dart @@ -25,7 +25,8 @@ Future launchUrlString( WebViewConfiguration webViewConfiguration = const WebViewConfiguration(), String? webOnlyWindowName, }) async { - if (mode == LaunchMode.inAppWebView && + if ((mode == LaunchMode.inAppWebView || + mode == LaunchMode.inAppBrowserView) && !(urlString.startsWith('https:') || urlString.startsWith('http:'))) { throw ArgumentError.value(urlString, 'urlString', 'To use an in-app web view, you must provide an http(s) URL.'); diff --git a/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart b/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart index b3ce6c279f..0626216569 100644 --- a/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart +++ b/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart @@ -11,25 +11,13 @@ import 'type_conversion.dart'; /// Passes [url] to the underlying platform for handling. /// -/// [mode] support varies significantly by platform: -/// - [LaunchMode.platformDefault] is supported on all platforms: -/// - On iOS and Android, this treats web URLs as -/// [LaunchMode.inAppWebView], and all other URLs as -/// [LaunchMode.externalApplication]. -/// - On Windows, macOS, and Linux this behaves like -/// [LaunchMode.externalApplication]. -/// - On web, this uses `webOnlyWindowName` for web URLs, and behaves like -/// [LaunchMode.externalApplication] for any other content. -/// - [LaunchMode.inAppWebView] is currently only supported on iOS and -/// Android. If a non-web URL is passed with this mode, an [ArgumentError] -/// will be thrown. -/// - [LaunchMode.externalApplication] is supported on all platforms. -/// On iOS, this should be used in cases where sharing the cookies of the -/// user's browser is important, such as SSO flows, since Safari View -/// Controller does not share the browser's context. -/// - [LaunchMode.externalNonBrowserApplication] is supported on iOS 10+. -/// This setting is used to require universal links to open in a non-browser -/// application. +/// [mode] support varies significantly by platform. Clients can use +/// [supportsLaunchMode] to query for support, but platforms will fall back to +/// other modes if the requested mode is not supported, so checking is not +/// required. The default behavior of [LaunchMode.platformDefault] is up to each +/// platform, and its behavior for a given platform may change over time as new +/// modes are supported, so clients that want a specific mode should request it +/// rather than rely on any currently observed default behavior. /// /// For web, [webOnlyWindowName] specifies a target for the launch. This /// supports the standard special link target names. For example: @@ -45,7 +33,8 @@ Future launchUrl( WebViewConfiguration webViewConfiguration = const WebViewConfiguration(), String? webOnlyWindowName, }) async { - if (mode == LaunchMode.inAppWebView && + if ((mode == LaunchMode.inAppWebView || + mode == LaunchMode.inAppBrowserView) && !(url.scheme == 'https' || url.scheme == 'http')) { throw ArgumentError.value(url, 'url', 'To use an in-app web view, you must provide an http(s) URL.'); @@ -81,8 +70,26 @@ Future canLaunchUrl(Uri url) async { /// Closes the current in-app web view, if one was previously opened by /// [launchUrl]. /// -/// If [launchUrl] was never called with [LaunchMode.inAppWebView], then this -/// call will have no effect. +/// This works only if [supportsCloseForLaunchMode] returns true for the mode +/// that was used by [launchUrl]. Future closeInAppWebView() async { return UrlLauncherPlatform.instance.closeWebView(); } + +/// Returns true if [mode] is supported by the current platform implementation. +/// +/// Calling [launchUrl] with an unsupported mode will fall back to a supported +/// mode, so calling this method is only necessary for cases where the caller +/// needs to know which mode will be used. +Future supportsLaunchMode(PreferredLaunchMode mode) { + return UrlLauncherPlatform.instance.supportsMode(mode); +} + +/// Returns true if [closeInAppWebView] is supported for [mode] in the current +/// platform implementation. +/// +/// If this returns false, [closeInAppWebView] will not work when launching +/// URLs with [mode]. +Future supportsCloseForLaunchMode(PreferredLaunchMode mode) { + return UrlLauncherPlatform.instance.supportsMode(mode); +} diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 9feae723f2..7546e6bf7d 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.1.14 +version: 6.2.0 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" flutter: plugin: @@ -28,15 +28,15 @@ flutter: dependencies: flutter: sdk: flutter - url_launcher_android: ^6.0.13 - url_launcher_ios: ^6.0.13 + url_launcher_android: ^6.2.0 + url_launcher_ios: ^6.2.0 # Allow either the pure-native or Dart/native hybrid versions of the desktop # implementations, as both are compatible. - url_launcher_linux: ">=2.0.0 <4.0.0" - url_launcher_macos: ">=2.0.0 <4.0.0" - url_launcher_platform_interface: ^2.1.0 - url_launcher_web: ^2.0.0 - url_launcher_windows: ">=2.0.0 <4.0.0" + url_launcher_linux: ^3.1.0 + url_launcher_macos: ^3.1.0 + url_launcher_platform_interface: ^2.2.0 + url_launcher_web: ^2.2.0 + url_launcher_windows: ^3.1.0 dev_dependencies: flutter_test: diff --git a/packages/url_launcher/url_launcher/test/link_test.dart b/packages/url_launcher/url_launcher/test/link_test.dart index 1585420d9b..052ca2556e 100644 --- a/packages/url_launcher/url_launcher/test/link_test.dart +++ b/packages/url_launcher/url_launcher/test/link_test.dart @@ -86,7 +86,7 @@ void main() { mock ..setLaunchExpectations( url: 'http://example.com/foobar', - launchMode: PreferredLaunchMode.inAppWebView, + launchMode: PreferredLaunchMode.inAppBrowserView, universalLinksOnly: false, enableJavaScript: true, enableDomStorage: true, diff --git a/packages/url_launcher/url_launcher/test/mocks/mock_url_launcher_platform.dart b/packages/url_launcher/url_launcher/test/mocks/mock_url_launcher_platform.dart index 05c8b5e4b3..fc0181d4a4 100644 --- a/packages/url_launcher/url_launcher/test/mocks/mock_url_launcher_platform.dart +++ b/packages/url_launcher/url_launcher/test/mocks/mock_url_launcher_platform.dart @@ -107,4 +107,16 @@ class MockUrlLauncher extends Fake Future closeWebView() async { closeWebViewCalled = true; } + + @override + Future supportsMode(PreferredLaunchMode mode) async { + launchMode = mode; + return response!; + } + + @override + Future supportsCloseForMode(PreferredLaunchMode mode) async { + launchMode = mode; + return response!; + } } diff --git a/packages/url_launcher/url_launcher/test/src/url_launcher_uri_test.dart b/packages/url_launcher/url_launcher/test/src/url_launcher_uri_test.dart index d71d07fc8f..0e6e76c38e 100644 --- a/packages/url_launcher/url_launcher/test/src/url_launcher_uri_test.dart +++ b/packages/url_launcher/url_launcher/test/src/url_launcher_uri_test.dart @@ -248,4 +248,40 @@ void main() { expect(await launchUrl(emailLaunchUrl), isTrue); }); }); + + group('supportsLaunchMode', () { + test('handles returning true', () async { + const PreferredLaunchMode mode = PreferredLaunchMode.inAppBrowserView; + mock.setResponse(true); + + expect(await supportsLaunchMode(mode), true); + expect(mock.launchMode, mode); + }); + + test('handles returning false', () async { + const PreferredLaunchMode mode = PreferredLaunchMode.inAppBrowserView; + mock.setResponse(false); + + expect(await supportsLaunchMode(mode), false); + expect(mock.launchMode, mode); + }); + }); + + group('supportsCloseForLaunchMode', () { + test('handles returning true', () async { + const PreferredLaunchMode mode = PreferredLaunchMode.inAppBrowserView; + mock.setResponse(true); + + expect(await supportsCloseForLaunchMode(mode), true); + expect(mock.launchMode, mode); + }); + + test('handles returning false', () async { + const PreferredLaunchMode mode = PreferredLaunchMode.inAppBrowserView; + mock.setResponse(false); + + expect(await supportsCloseForLaunchMode(mode), false); + expect(mock.launchMode, mode); + }); + }); }