[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:
Rajesh Malviya
2023-09-01 00:46:04 +05:30
committed by GitHub
parent bd4a8eb6a4
commit e668c436dc
13 changed files with 192 additions and 11 deletions

View File

@ -1,3 +1,7 @@
## 6.1.14
* Updates documentation to mention support for Android Custom Tabs.
## 6.1.13 ## 6.1.13
* Adds pub topics to package metadata. * Adds pub topics to package metadata.

View File

@ -63,6 +63,12 @@ class _MyHomePageState extends State<MyHomePage> {
} }
Future<void> _launchInWebViewOrVC(Uri url) async { 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( if (!await launchUrl(
url, url,
mode: LaunchMode.inAppWebView, mode: LaunchMode.inAppWebView,
@ -171,6 +177,12 @@ class _MyHomePageState extends State<MyHomePage> {
}), }),
child: const Text('Launch in app'), child: const Text('Launch in app'),
), ),
ElevatedButton(
onPressed: () => setState(() {
_launched = _launchAsInAppWebViewWithCustomHeaders(toLaunch);
}),
child: const Text('Launch in app (Custom Headers)'),
),
ElevatedButton( ElevatedButton(
onPressed: () => setState(() { onPressed: () => setState(() {
_launched = _launchInWebViewWithoutJavaScript(toLaunch); _launched = _launchInWebViewWithoutJavaScript(toLaunch);

View File

@ -14,7 +14,7 @@ enum LaunchMode {
/// implementation. /// implementation.
platformDefault, 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, inAppWebView,
/// Passes the URL to the OS to be handled by another application. /// Passes the URL to the OS to be handled by another application.

View File

@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports
web, phone, SMS, and email schemes. web, phone, SMS, and email schemes.
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher 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 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: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"

View File

@ -1,3 +1,7 @@
## 6.1.0
* Adds support for Android Custom Tabs.
## 6.0.39 ## 6.0.39
* Adds pub topics to package metadata. * Adds pub topics to package metadata.

View File

@ -66,6 +66,7 @@ dependencies {
// Java language implementation // Java language implementation
implementation "androidx.core:core:1.10.1" implementation "androidx.core:core:1.10.1"
implementation 'androidx.annotation:annotation:1.6.0' implementation 'androidx.annotation:annotation:1.6.0'
implementation 'androidx.browser:browser:1.5.0'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.1.1' testImplementation 'org.mockito:mockito-core:5.1.1'
testImplementation 'androidx.test:core:1.0.0' testImplementation 'androidx.test:core:1.0.0'

View File

@ -16,8 +16,10 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import androidx.browser.customtabs.CustomTabsIntent;
import io.flutter.plugins.urllauncher.Messages.UrlLauncherApi; import io.flutter.plugins.urllauncher.Messages.UrlLauncherApi;
import io.flutter.plugins.urllauncher.Messages.WebViewOptions; import io.flutter.plugins.urllauncher.Messages.WebViewOptions;
import java.util.Locale;
import java.util.Map; import java.util.Map;
/** Implements the Pigeon-defined interface for calls from Dart. */ /** Implements the Pigeon-defined interface for calls from Dart. */
@ -97,13 +99,24 @@ final class UrlLauncher implements UrlLauncherApi {
ensureActivity(); ensureActivity();
assert activity != null; 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 = Intent launchIntent =
WebViewActivity.createIntent( WebViewActivity.createIntent(
activity, activity,
url, url,
options.getEnableJavaScript(), options.getEnableJavaScript(),
options.getEnableDomStorage(), options.getEnableDomStorage(),
extractBundle(options.getHeaders())); headersBundle);
try { try {
activity.startActivity(launchIntent); activity.startActivity(launchIntent);
} catch (ActivityNotFoundException e) { } catch (ActivityNotFoundException e) {
@ -118,6 +131,35 @@ final class UrlLauncher implements UrlLauncherApi {
applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE)); 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) { private static @NonNull Bundle extractBundle(Map<String, String> headersMap) {
final Bundle headersBundle = new Bundle(); final Bundle headersBundle = new Bundle();
for (String key : headersMap.keySet()) { for (String key : headersMap.keySet()) {

View File

@ -6,9 +6,11 @@ package io.flutter.plugins.urllauncher;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -128,13 +130,15 @@ public class UrlLauncherTest {
} }
@Test @Test
public void openWebView_opensUrl() { public void openWebView_opensUrl_inWebView() {
Activity activity = mock(Activity.class); Activity activity = mock(Activity.class);
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
api.setActivity(activity); api.setActivity(activity);
String url = "https://flutter.dev"; String url = "https://flutter.dev";
boolean enableJavaScript = false; boolean enableJavaScript = false;
boolean enableDomStorage = false; boolean enableDomStorage = false;
HashMap<String, String> headers = new HashMap<>();
headers.put("key", "value");
boolean result = boolean result =
api.openUrlInWebView( api.openUrlInWebView(
@ -142,7 +146,7 @@ public class UrlLauncherTest {
new Messages.WebViewOptions.Builder() new Messages.WebViewOptions.Builder()
.setEnableJavaScript(enableJavaScript) .setEnableJavaScript(enableJavaScript)
.setEnableDomStorage(enableDomStorage) .setEnableDomStorage(enableDomStorage)
.setHeaders(new HashMap<>()) .setHeaders(headers)
.build()); .build());
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class); final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@ -157,19 +161,102 @@ public class UrlLauncherTest {
intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA)); 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 @Test
public void openWebView_handlesEnableJavaScript() { public void openWebView_handlesEnableJavaScript() {
Activity activity = mock(Activity.class); Activity activity = mock(Activity.class);
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
api.setActivity(activity); api.setActivity(activity);
boolean enableJavaScript = true; boolean enableJavaScript = true;
HashMap<String, String> headers = new HashMap<>();
headers.put("key", "value");
api.openUrlInWebView( api.openUrlInWebView(
"https://flutter.dev", "https://flutter.dev",
new Messages.WebViewOptions.Builder() new Messages.WebViewOptions.Builder()
.setEnableJavaScript(enableJavaScript) .setEnableJavaScript(enableJavaScript)
.setEnableDomStorage(false) .setEnableDomStorage(false)
.setHeaders(new HashMap<>()) .setHeaders(headers)
.build()); .build());
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class); final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@ -213,13 +300,15 @@ public class UrlLauncherTest {
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
api.setActivity(activity); api.setActivity(activity);
boolean enableDomStorage = true; boolean enableDomStorage = true;
HashMap<String, String> headers = new HashMap<>();
headers.put("key", "value");
api.openUrlInWebView( api.openUrlInWebView(
"https://flutter.dev", "https://flutter.dev",
new Messages.WebViewOptions.Builder() new Messages.WebViewOptions.Builder()
.setEnableJavaScript(false) .setEnableJavaScript(false)
.setEnableDomStorage(enableDomStorage) .setEnableDomStorage(enableDomStorage)
.setHeaders(new HashMap<>()) .setHeaders(headers)
.build()); .build());
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class); final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@ -253,7 +342,12 @@ public class UrlLauncherTest {
Activity activity = mock(Activity.class); Activity activity = mock(Activity.class);
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
api.setActivity(activity); 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 = boolean result =
api.openUrlInWebView( api.openUrlInWebView(

View File

@ -68,6 +68,20 @@ class _MyHomePageState extends State<MyHomePage> {
} }
Future<void> _launchInWebView(String url) async { 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( if (!await launcher.launch(
url, url,
useSafariVC: true, useSafariVC: true,
@ -185,6 +199,12 @@ class _MyHomePageState extends State<MyHomePage> {
}), }),
child: const Text('Launch in app'), child: const Text('Launch in app'),
), ),
ElevatedButton(
onPressed: () => setState(() {
_launched = _launchInWebViewWithCustomHeaders(toLaunch);
}),
child: const Text('Launch in app (Custom headers)'),
),
ElevatedButton( ElevatedButton(
onPressed: () => setState(() { onPressed: () => setState(() {
_launched = _launchInWebViewWithJavaScript(toLaunch); _launched = _launchInWebViewWithJavaScript(toLaunch);

View File

@ -2,7 +2,7 @@ name: url_launcher_android
description: Android implementation of the url_launcher plugin. description: Android implementation of the url_launcher plugin.
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android 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 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: environment:
sdk: ">=2.19.0 <4.0.0" sdk: ">=2.19.0 <4.0.0"
flutter: ">=3.7.0" flutter: ">=3.7.0"

View File

@ -1,3 +1,7 @@
## 2.1.5
* Updates documentation to mention support for Android Custom Tabs.
## 2.1.4 ## 2.1.4
* Adds pub topics to package metadata. * Adds pub topics to package metadata.

View File

@ -13,7 +13,7 @@ enum PreferredLaunchMode {
/// implementation. /// implementation.
platformDefault, 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, inAppWebView,
/// Passes the URL to the OS to be handled by another application. /// Passes the URL to the OS to be handled by another application.

View File

@ -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 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 # 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 # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
version: 2.1.4 version: 2.1.5
environment: environment:
sdk: ">=2.19.0 <4.0.0" sdk: ">=2.19.0 <4.0.0"