[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"