Skip to content

Commit

Permalink
[url_launcher_android] Add support for Custom Tabs (#4739)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rajveermalviya authored Aug 31, 2023
1 parent bd4a8eb commit e668c43
Show file tree
Hide file tree
Showing 13 changed files with 192 additions and 11 deletions.
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;
}
}

// 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) {
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));
}

@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
20 changes: 20 additions & 0 deletions packages/url_launcher/url_launcher_android/example/lib/main.dart
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

0 comments on commit e668c43

Please sign in to comment.