Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[url_launcher_android] Add support for Custom Tabs #4739

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/url_launcher/url_launcher/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/url_launcher/url_launcher/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/url_launcher/url_launcher/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/url_launcher/url_launcher/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/url_launcher/url_launcher_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 6.1.0

* Adds support for Android Custom Tabs.

## 6.0.39

* Adds pub topics to package metadata.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -97,13 +99,24 @@ void setActivity(@Nullable Activity activity) {
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;
}
}
stuartmorgan marked this conversation as resolved.
Show resolved Hide resolved

// 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) {
Expand All @@ -118,6 +131,35 @@ public void closeWebView() {
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are additional restrictions listed in the link - my understanding is that if we try to launch with the safelisted headers, but in a case that fails the additional restrictions, we will simply fail and fall back to the webview mode.

Do you have a sense of how frequently we would end up in this case of mismatch between containsRestrictedHeader, and the source of truth link? Is it worth complicating this check to make it line up fully?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Digging into this a bit more, Chromium has a lot more exhaustive list of headers and checks for the values. Should the same be done here?
(Disclaimer; BSD licensed) - https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/cors/cors.cc;l=278-425;drc=86339296c23927c8e8abb6cd81c8cb8d4bb3700e

Problem with not doing this correctly, that is check everything header+values, chrome & firefox will just ignore the restricted headers and the page will be opened in the custom tabs without those headers, it doesn't fail to open custom tab. So we can't fallback to webview if that happens, unless we do the check ourselves.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference would be to not add a bunch of complexity without knowing if anyone actually needs it. I would vote for keeping the current basic check, and see if we get feedback about it. Anyone affected can trivially work around it in the meantime by just adding an arbitrary other header to the request (e.g., add 'do-not-use-custom-tabs': true to the header map).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyone affected can trivially work around it in the meantime by just adding an arbitrary other header to the request (e.g., add 'do-not-use-custom-tabs': true to the header map).

Good point, waiting and seeing if we get feedback SGTM with that in mind

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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,21 +130,23 @@ public void launch_returnsTrue() {
}

@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(
url,
new Messages.WebViewOptions.Builder()
.setEnableJavaScript(enableJavaScript)
.setEnableDomStorage(enableDomStorage)
.setHeaders(new HashMap<>())
.setHeaders(headers)
.build());

final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
Expand All @@ -157,19 +161,102 @@ public void openWebView_opensUrl() {
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));
}

stuartmorgan marked this conversation as resolved.
Show resolved Hide resolved
@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);
Expand Down Expand Up @@ -213,13 +300,15 @@ public void openWebView_handlesEnableDomStorage() {
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);
Expand Down Expand Up @@ -253,7 +342,12 @@ public void openWebView_returnsFalse() {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/url_launcher/url_launcher_android/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down