[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
diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md
index 75b3d6f..cebc817 100644
--- a/packages/url_launcher/url_launcher/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher/CHANGELOG.md
@@ -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.
diff --git a/packages/url_launcher/url_launcher/example/lib/main.dart b/packages/url_launcher/url_launcher/example/lib/main.dart
index f88fcfe..57a0ce9 100644
--- a/packages/url_launcher/url_launcher/example/lib/main.dart
+++ b/packages/url_launcher/url_launcher/example/lib/main.dart
@@ -63,6 +63,12 @@
}
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,
@@ -173,6 +179,12 @@
),
ElevatedButton(
onPressed: () => setState(() {
+ _launched = _launchAsInAppWebViewWithCustomHeaders(toLaunch);
+ }),
+ child: const Text('Launch in app (Custom Headers)'),
+ ),
+ ElevatedButton(
+ onPressed: () => setState(() {
_launched = _launchInWebViewWithoutJavaScript(toLaunch);
}),
child: const Text('Launch in app (JavaScript OFF)'),
diff --git a/packages/url_launcher/url_launcher/lib/src/types.dart b/packages/url_launcher/url_launcher/lib/src/types.dart
index bcfcb78..359e293 100644
--- a/packages/url_launcher/url_launcher/lib/src/types.dart
+++ b/packages/url_launcher/url_launcher/lib/src/types.dart
@@ -14,7 +14,7 @@
/// 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.
diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml
index 44f01a4..9feae72 100644
--- a/packages/url_launcher/url_launcher/pubspec.yaml
+++ b/packages/url_launcher/url_launcher/pubspec.yaml
@@ -3,7 +3,7 @@
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"
diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md
index beda537..ed8a883 100644
--- a/packages/url_launcher/url_launcher_android/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 6.1.0
+
+* Adds support for Android Custom Tabs.
+
## 6.0.39
* Adds pub topics to package metadata.
diff --git a/packages/url_launcher/url_launcher_android/android/build.gradle b/packages/url_launcher/url_launcher_android/android/build.gradle
index 2ddd182..dad5ba7 100644
--- a/packages/url_launcher/url_launcher_android/android/build.gradle
+++ b/packages/url_launcher/url_launcher_android/android/build.gradle
@@ -66,6 +66,7 @@
// 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'
diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java
index bb280a8..8ee9bff 100644
--- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java
+++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java
@@ -16,8 +16,10 @@
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 @@
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 @@
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()) {
diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java
index e87def0..b8bb3b4 100644
--- a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java
+++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java
@@ -6,9 +6,11 @@
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 @@
}
@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 @@
new Messages.WebViewOptions.Builder()
.setEnableJavaScript(enableJavaScript)
.setEnableDomStorage(enableDomStorage)
- .setHeaders(new HashMap<>())
+ .setHeaders(headers)
.build());
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@@ -158,18 +162,101 @@
}
@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 @@
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 @@
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(
diff --git a/packages/url_launcher/url_launcher_android/example/lib/main.dart b/packages/url_launcher/url_launcher_android/example/lib/main.dart
index d10681b..df28069 100644
--- a/packages/url_launcher/url_launcher_android/example/lib/main.dart
+++ b/packages/url_launcher/url_launcher_android/example/lib/main.dart
@@ -75,6 +75,20 @@
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,
+ useWebView: true,
+ enableJavaScript: false,
+ enableDomStorage: false,
+ universalLinksOnly: false,
headers: <String, String>{'my_header_key': 'my_header_value'},
)) {
throw Exception('Could not launch $url');
@@ -187,6 +201,12 @@
),
ElevatedButton(
onPressed: () => setState(() {
+ _launched = _launchInWebViewWithCustomHeaders(toLaunch);
+ }),
+ child: const Text('Launch in app (Custom headers)'),
+ ),
+ ElevatedButton(
+ onPressed: () => setState(() {
_launched = _launchInWebViewWithJavaScript(toLaunch);
}),
child: const Text('Launch in app (JavaScript ON)'),
diff --git a/packages/url_launcher/url_launcher_android/pubspec.yaml b/packages/url_launcher/url_launcher_android/pubspec.yaml
index 9ac8dbb..f5db21c 100644
--- a/packages/url_launcher/url_launcher_android/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_android/pubspec.yaml
@@ -2,7 +2,7 @@
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"
diff --git a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md
index 2a9da45..f5523ce 100644
--- a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md
@@ -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.
diff --git a/packages/url_launcher/url_launcher_platform_interface/lib/src/types.dart b/packages/url_launcher/url_launcher_platform_interface/lib/src/types.dart
index 08d87e0..ca9d8e1 100644
--- a/packages/url_launcher/url_launcher_platform_interface/lib/src/types.dart
+++ b/packages/url_launcher/url_launcher_platform_interface/lib/src/types.dart
@@ -13,7 +13,7 @@
/// 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.
diff --git a/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml b/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml
index c4dd9c3..5aa135f 100644
--- a/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml
@@ -4,7 +4,7 @@
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"