mirror of
https://github.com/flutter/packages.git
synced 2025-06-27 21:28:33 +08:00
[url_launcher_android] Add support for Custom Tabs (#4739)
Implement support for [Android Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/). Custom Tabs will only be used if *__all__* of the following conditions are true: - `launchMode` == `LaunchMode.inAppWebView` (or `LaunchMode.platformDefault`; only if url is web url) - `WebViewConfiguration.headers` == `{}` (or if it only contains [CORS-safelisted headers](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header)) Fixes flutter/flutter#18589
This commit is contained in:
@ -1,3 +1,7 @@
|
||||
## 6.1.14
|
||||
|
||||
* Updates documentation to mention support for Android Custom Tabs.
|
||||
|
||||
## 6.1.13
|
||||
|
||||
* Adds pub topics to package metadata.
|
||||
|
@ -63,6 +63,12 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}
|
||||
|
||||
Future<void> _launchInWebViewOrVC(Uri url) async {
|
||||
if (!await launchUrl(url, mode: LaunchMode.inAppWebView)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchAsInAppWebViewWithCustomHeaders(Uri url) async {
|
||||
if (!await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.inAppWebView,
|
||||
@ -171,6 +177,12 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}),
|
||||
child: const Text('Launch in app'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() {
|
||||
_launched = _launchAsInAppWebViewWithCustomHeaders(toLaunch);
|
||||
}),
|
||||
child: const Text('Launch in app (Custom Headers)'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() {
|
||||
_launched = _launchInWebViewWithoutJavaScript(toLaunch);
|
||||
|
@ -14,7 +14,7 @@ enum LaunchMode {
|
||||
/// implementation.
|
||||
platformDefault,
|
||||
|
||||
/// Loads the URL in an in-app web view (e.g., Safari View Controller).
|
||||
/// Loads the URL in an in-app web view (e.g., Android Custom Tabs, Safari View Controller).
|
||||
inAppWebView,
|
||||
|
||||
/// Passes the URL to the OS to be handled by another application.
|
||||
|
@ -3,7 +3,7 @@ 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.13
|
||||
version: 6.1.14
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
@ -1,3 +1,7 @@
|
||||
## 6.1.0
|
||||
|
||||
* Adds support for Android Custom Tabs.
|
||||
|
||||
## 6.0.39
|
||||
|
||||
* Adds pub topics to package metadata.
|
||||
|
@ -66,6 +66,7 @@ dependencies {
|
||||
// Java language implementation
|
||||
implementation "androidx.core:core:1.10.1"
|
||||
implementation 'androidx.annotation:annotation:1.6.0'
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.1.1'
|
||||
testImplementation 'androidx.test:core:1.0.0'
|
||||
|
@ -16,8 +16,10 @@ import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import io.flutter.plugins.urllauncher.Messages.UrlLauncherApi;
|
||||
import io.flutter.plugins.urllauncher.Messages.WebViewOptions;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Implements the Pigeon-defined interface for calls from Dart. */
|
||||
@ -97,13 +99,24 @@ final class UrlLauncher implements UrlLauncherApi {
|
||||
ensureActivity();
|
||||
assert activity != null;
|
||||
|
||||
Bundle headersBundle = extractBundle(options.getHeaders());
|
||||
|
||||
// Try to launch using Custom Tabs if they have the necessary functionality.
|
||||
if (!containsRestrictedHeader(options.getHeaders())) {
|
||||
Uri uri = Uri.parse(url);
|
||||
if (openCustomTab(activity, uri, headersBundle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to a web view if necessary.
|
||||
Intent launchIntent =
|
||||
WebViewActivity.createIntent(
|
||||
activity,
|
||||
url,
|
||||
options.getEnableJavaScript(),
|
||||
options.getEnableDomStorage(),
|
||||
extractBundle(options.getHeaders()));
|
||||
headersBundle);
|
||||
try {
|
||||
activity.startActivity(launchIntent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
@ -118,6 +131,35 @@ final class UrlLauncher implements UrlLauncherApi {
|
||||
applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE));
|
||||
}
|
||||
|
||||
private static boolean openCustomTab(
|
||||
@NonNull Context context, @NonNull Uri uri, @NonNull Bundle headersBundle) {
|
||||
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build();
|
||||
customTabsIntent.intent.putExtra(Browser.EXTRA_HEADERS, headersBundle);
|
||||
try {
|
||||
customTabsIntent.launchUrl(context, uri);
|
||||
} catch (ActivityNotFoundException ex) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks if headers contains a CORS restricted header.
|
||||
// https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
|
||||
private static boolean containsRestrictedHeader(Map<String, String> headersMap) {
|
||||
for (String key : headersMap.keySet()) {
|
||||
switch (key.toLowerCase(Locale.US)) {
|
||||
case "accept":
|
||||
case "accept-language":
|
||||
case "content-language":
|
||||
case "content-type":
|
||||
continue;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static @NonNull Bundle extractBundle(Map<String, String> headersMap) {
|
||||
final Bundle headersBundle = new Bundle();
|
||||
for (String key : headersMap.keySet()) {
|
||||
|
@ -6,9 +6,11 @@ package io.flutter.plugins.urllauncher;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@ -128,13 +130,15 @@ public class UrlLauncherTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openWebView_opensUrl() {
|
||||
public void openWebView_opensUrl_inWebView() {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
String url = "https://flutter.dev";
|
||||
boolean enableJavaScript = false;
|
||||
boolean enableDomStorage = false;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
headers.put("key", "value");
|
||||
|
||||
boolean result =
|
||||
api.openUrlInWebView(
|
||||
@ -142,7 +146,7 @@ public class UrlLauncherTest {
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(enableJavaScript)
|
||||
.setEnableDomStorage(enableDomStorage)
|
||||
.setHeaders(new HashMap<>())
|
||||
.setHeaders(headers)
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
@ -157,19 +161,102 @@ public class UrlLauncherTest {
|
||||
intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openWebView_opensUrl_inCustomTabs() {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
String url = "https://flutter.dev";
|
||||
|
||||
boolean result =
|
||||
api.openUrlInWebView(
|
||||
url,
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(false)
|
||||
.setEnableDomStorage(false)
|
||||
.setHeaders(new HashMap<>())
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
verify(activity).startActivity(intentCaptor.capture(), isNull());
|
||||
assertTrue(result);
|
||||
assertEquals(Intent.ACTION_VIEW, intentCaptor.getValue().getAction());
|
||||
assertNull(intentCaptor.getValue().getComponent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openWebView_opensUrl_inCustomTabs_withCORSAllowedHeader() {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
String url = "https://flutter.dev";
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
String headerKey = "Content-Type";
|
||||
headers.put(headerKey, "text/plain");
|
||||
|
||||
boolean result =
|
||||
api.openUrlInWebView(
|
||||
url,
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(false)
|
||||
.setEnableDomStorage(false)
|
||||
.setHeaders(headers)
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
verify(activity).startActivity(intentCaptor.capture(), isNull());
|
||||
assertTrue(result);
|
||||
assertEquals(Intent.ACTION_VIEW, intentCaptor.getValue().getAction());
|
||||
assertNull(intentCaptor.getValue().getComponent());
|
||||
final Bundle passedHeaders =
|
||||
intentCaptor.getValue().getExtras().getBundle(Browser.EXTRA_HEADERS);
|
||||
assertEquals(headers.get(headerKey), passedHeaders.getString(headerKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openWebView_fallsbackTo_inWebView() {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
String url = "https://flutter.dev";
|
||||
doThrow(new ActivityNotFoundException())
|
||||
.when(activity)
|
||||
.startActivity(any(), isNull()); // for custom tabs intent
|
||||
|
||||
boolean result =
|
||||
api.openUrlInWebView(
|
||||
url,
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(false)
|
||||
.setEnableDomStorage(false)
|
||||
.setHeaders(new HashMap<>())
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
verify(activity).startActivity(intentCaptor.capture());
|
||||
assertTrue(result);
|
||||
assertEquals(url, intentCaptor.getValue().getExtras().getString(WebViewActivity.URL_EXTRA));
|
||||
assertEquals(
|
||||
false, intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_JS_EXTRA));
|
||||
assertEquals(
|
||||
false, intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openWebView_handlesEnableJavaScript() {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
boolean enableJavaScript = true;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
headers.put("key", "value");
|
||||
|
||||
api.openUrlInWebView(
|
||||
"https://flutter.dev",
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(enableJavaScript)
|
||||
.setEnableDomStorage(false)
|
||||
.setHeaders(new HashMap<>())
|
||||
.setHeaders(headers)
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
@ -213,13 +300,15 @@ public class UrlLauncherTest {
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
boolean enableDomStorage = true;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
headers.put("key", "value");
|
||||
|
||||
api.openUrlInWebView(
|
||||
"https://flutter.dev",
|
||||
new Messages.WebViewOptions.Builder()
|
||||
.setEnableJavaScript(false)
|
||||
.setEnableDomStorage(enableDomStorage)
|
||||
.setHeaders(new HashMap<>())
|
||||
.setHeaders(headers)
|
||||
.build());
|
||||
|
||||
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
|
||||
@ -253,7 +342,12 @@ public class UrlLauncherTest {
|
||||
Activity activity = mock(Activity.class);
|
||||
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
|
||||
api.setActivity(activity);
|
||||
doThrow(new ActivityNotFoundException()).when(activity).startActivity(any());
|
||||
doThrow(new ActivityNotFoundException())
|
||||
.when(activity)
|
||||
.startActivity(any(), isNull()); // for custom tabs intent
|
||||
doThrow(new ActivityNotFoundException())
|
||||
.when(activity)
|
||||
.startActivity(any()); // for webview intent
|
||||
|
||||
boolean result =
|
||||
api.openUrlInWebView(
|
||||
|
@ -68,6 +68,20 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}
|
||||
|
||||
Future<void> _launchInWebView(String url) async {
|
||||
if (!await launcher.launch(
|
||||
url,
|
||||
useSafariVC: true,
|
||||
useWebView: true,
|
||||
enableJavaScript: false,
|
||||
enableDomStorage: false,
|
||||
universalLinksOnly: false,
|
||||
headers: <String, String>{},
|
||||
)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchInWebViewWithCustomHeaders(String url) async {
|
||||
if (!await launcher.launch(
|
||||
url,
|
||||
useSafariVC: true,
|
||||
@ -185,6 +199,12 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
}),
|
||||
child: const Text('Launch in app'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() {
|
||||
_launched = _launchInWebViewWithCustomHeaders(toLaunch);
|
||||
}),
|
||||
child: const Text('Launch in app (Custom headers)'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() {
|
||||
_launched = _launchInWebViewWithJavaScript(toLaunch);
|
||||
|
@ -2,7 +2,7 @@ name: url_launcher_android
|
||||
description: Android implementation of the url_launcher plugin.
|
||||
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android
|
||||
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
|
||||
version: 6.0.39
|
||||
version: 6.1.0
|
||||
environment:
|
||||
sdk: ">=2.19.0 <4.0.0"
|
||||
flutter: ">=3.7.0"
|
||||
|
@ -1,3 +1,7 @@
|
||||
## 2.1.5
|
||||
|
||||
* Updates documentation to mention support for Android Custom Tabs.
|
||||
|
||||
## 2.1.4
|
||||
|
||||
* Adds pub topics to package metadata.
|
||||
|
@ -13,7 +13,7 @@ enum PreferredLaunchMode {
|
||||
/// implementation.
|
||||
platformDefault,
|
||||
|
||||
/// Loads the URL in an in-app web view (e.g., Safari View Controller).
|
||||
/// Loads the URL in an in-app web view (e.g., Android Custom Tabs, Safari View Controller).
|
||||
inAppWebView,
|
||||
|
||||
/// Passes the URL to the OS to be handled by another application.
|
||||
|
@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/
|
||||
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
|
||||
# NOTE: We strongly prefer non-breaking changes, even at the expense of a
|
||||
# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
|
||||
version: 2.1.4
|
||||
version: 2.1.5
|
||||
|
||||
environment:
|
||||
sdk: ">=2.19.0 <4.0.0"
|
||||
|
Reference in New Issue
Block a user