[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
This commit is contained in:
stuartmorgan
2023-10-24 12:05:01 -07:00
committed by GitHub
parent 3cc6e26ea8
commit c6821f9702
14 changed files with 145 additions and 47 deletions

View File

@ -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.

View File

@ -66,7 +66,9 @@ See [`-[UIApplication canOpenURL:]`](https://developer.apple.com/documentation/u
Add any URL schemes passed to `canLaunchUrl` as `<queries>` entries in your
`AndroidManifest.xml`, otherwise it will return false in most cases starting
on Android 11 (API 30) or higher. A `<queries>`
on Android 11 (API 30) or higher. Checking for
`supportsLaunchMode(PreferredLaunchMode.inAppBrowserView)` also requires
a `<queries>` entry to return anything but false. A `<queries>`
element must be added to your manifest as a child of the root element.
Example:
@ -85,6 +87,10 @@ Example:
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" />
</intent>
<!-- If your application checks for inAppBrowserView launch mode support -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
```
@ -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`.

View File

@ -20,6 +20,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" />
</intent>
<!-- If your application checks for inAppBrowserView launch mode support -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
<!--#enddocregion android-queries-->
<!-- The "https" scheme is only required for integration tests of this package.
It shouldn't be needed in most actual apps, or show up in the README! -->

View File

@ -62,7 +62,13 @@ class _MyHomePageState extends State<MyHomePage> {
}
}
Future<void> _launchInWebViewOrVC(Uri url) async {
Future<void> _launchInBrowserView(Uri url) async {
if (!await launchUrl(url, mode: LaunchMode.inAppBrowserView)) {
throw Exception('Could not launch $url');
}
}
Future<void> _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<MyHomePage> {
}
}
Future<void> _launchUniversalLinkIos(Uri url) async {
Future<void> _launchUniversalLinkIOS(Uri url) async {
final bool nativeAppLaunchSucceeded = await launchUrl(
url,
mode: LaunchMode.externalNonBrowserApplication,
@ -107,7 +113,7 @@ class _MyHomePageState extends State<MyHomePage> {
if (!nativeAppLaunchSucceeded) {
await launchUrl(
url,
mode: LaunchMode.inAppWebView,
mode: LaunchMode.inAppBrowserView,
);
}
}
@ -173,7 +179,7 @@ class _MyHomePageState extends State<MyHomePage> {
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<MyHomePage> {
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<MyHomePage> {
const Padding(padding: EdgeInsets.all(16.0)),
ElevatedButton(
onPressed: () => setState(() {
_launched = _launchInWebViewOrVC(toLaunch);
_launched = _launchInWebView(toLaunch);
Timer(const Duration(seconds: 5), () {
closeInAppWebView();
});

View File

@ -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:

View File

@ -126,7 +126,7 @@ class DefaultLinkDelegate extends StatelessWidget {
success = await launchUrl(
url,
mode: _useWebView
? LaunchMode.inAppWebView
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication,
);
} on PlatformException {

View File

@ -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:

View File

@ -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,

View File

@ -25,7 +25,8 @@ Future<bool> 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.');

View File

@ -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<bool> 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<bool> 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<void> 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<bool> 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<bool> supportsCloseForLaunchMode(PreferredLaunchMode mode) {
return UrlLauncherPlatform.instance.supportsMode(mode);
}

View File

@ -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:

View File

@ -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,

View File

@ -107,4 +107,16 @@ class MockUrlLauncher extends Fake
Future<void> closeWebView() async {
closeWebViewCalled = true;
}
@override
Future<bool> supportsMode(PreferredLaunchMode mode) async {
launchMode = mode;
return response!;
}
@override
Future<bool> supportsCloseForMode(PreferredLaunchMode mode) async {
launchMode = mode;
return response!;
}
}

View File

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