Skip to content

Commit e668c43

Browse files
[url_launcher_android] Add support for Custom Tabs (flutter#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#18589
1 parent bd4a8eb commit e668c43

File tree

13 files changed

+192
-11
lines changed

13 files changed

+192
-11
lines changed

packages/url_launcher/url_launcher/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 6.1.14
2+
3+
* Updates documentation to mention support for Android Custom Tabs.
4+
15
## 6.1.13
26

37
* Adds pub topics to package metadata.

packages/url_launcher/url_launcher/example/lib/main.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class _MyHomePageState extends State<MyHomePage> {
6363
}
6464

6565
Future<void> _launchInWebViewOrVC(Uri url) async {
66+
if (!await launchUrl(url, mode: LaunchMode.inAppWebView)) {
67+
throw Exception('Could not launch $url');
68+
}
69+
}
70+
71+
Future<void> _launchAsInAppWebViewWithCustomHeaders(Uri url) async {
6672
if (!await launchUrl(
6773
url,
6874
mode: LaunchMode.inAppWebView,
@@ -171,6 +177,12 @@ class _MyHomePageState extends State<MyHomePage> {
171177
}),
172178
child: const Text('Launch in app'),
173179
),
180+
ElevatedButton(
181+
onPressed: () => setState(() {
182+
_launched = _launchAsInAppWebViewWithCustomHeaders(toLaunch);
183+
}),
184+
child: const Text('Launch in app (Custom Headers)'),
185+
),
174186
ElevatedButton(
175187
onPressed: () => setState(() {
176188
_launched = _launchInWebViewWithoutJavaScript(toLaunch);

packages/url_launcher/url_launcher/lib/src/types.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ enum LaunchMode {
1414
/// implementation.
1515
platformDefault,
1616

17-
/// Loads the URL in an in-app web view (e.g., Safari View Controller).
17+
/// Loads the URL in an in-app web view (e.g., Android Custom Tabs, Safari View Controller).
1818
inAppWebView,
1919

2020
/// Passes the URL to the OS to be handled by another application.

packages/url_launcher/url_launcher/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports
33
web, phone, SMS, and email schemes.
44
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
6-
version: 6.1.13
6+
version: 6.1.14
77

88
environment:
99
sdk: ">=3.0.0 <4.0.0"

packages/url_launcher/url_launcher_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 6.1.0
2+
3+
* Adds support for Android Custom Tabs.
4+
15
## 6.0.39
26

37
* Adds pub topics to package metadata.

packages/url_launcher/url_launcher_android/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ dependencies {
6666
// Java language implementation
6767
implementation "androidx.core:core:1.10.1"
6868
implementation 'androidx.annotation:annotation:1.6.0'
69+
implementation 'androidx.browser:browser:1.5.0'
6970
testImplementation 'junit:junit:4.13.2'
7071
testImplementation 'org.mockito:mockito-core:5.1.1'
7172
testImplementation 'androidx.test:core:1.0.0'

packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import androidx.annotation.NonNull;
1717
import androidx.annotation.Nullable;
1818
import androidx.annotation.VisibleForTesting;
19+
import androidx.browser.customtabs.CustomTabsIntent;
1920
import io.flutter.plugins.urllauncher.Messages.UrlLauncherApi;
2021
import io.flutter.plugins.urllauncher.Messages.WebViewOptions;
22+
import java.util.Locale;
2123
import java.util.Map;
2224

2325
/** Implements the Pigeon-defined interface for calls from Dart. */
@@ -97,13 +99,24 @@ void setActivity(@Nullable Activity activity) {
9799
ensureActivity();
98100
assert activity != null;
99101

102+
Bundle headersBundle = extractBundle(options.getHeaders());
103+
104+
// Try to launch using Custom Tabs if they have the necessary functionality.
105+
if (!containsRestrictedHeader(options.getHeaders())) {
106+
Uri uri = Uri.parse(url);
107+
if (openCustomTab(activity, uri, headersBundle)) {
108+
return true;
109+
}
110+
}
111+
112+
// Fall back to a web view if necessary.
100113
Intent launchIntent =
101114
WebViewActivity.createIntent(
102115
activity,
103116
url,
104117
options.getEnableJavaScript(),
105118
options.getEnableDomStorage(),
106-
extractBundle(options.getHeaders()));
119+
headersBundle);
107120
try {
108121
activity.startActivity(launchIntent);
109122
} catch (ActivityNotFoundException e) {
@@ -118,6 +131,35 @@ public void closeWebView() {
118131
applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE));
119132
}
120133

134+
private static boolean openCustomTab(
135+
@NonNull Context context, @NonNull Uri uri, @NonNull Bundle headersBundle) {
136+
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build();
137+
customTabsIntent.intent.putExtra(Browser.EXTRA_HEADERS, headersBundle);
138+
try {
139+
customTabsIntent.launchUrl(context, uri);
140+
} catch (ActivityNotFoundException ex) {
141+
return false;
142+
}
143+
return true;
144+
}
145+
146+
// Checks if headers contains a CORS restricted header.
147+
// https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
148+
private static boolean containsRestrictedHeader(Map<String, String> headersMap) {
149+
for (String key : headersMap.keySet()) {
150+
switch (key.toLowerCase(Locale.US)) {
151+
case "accept":
152+
case "accept-language":
153+
case "content-language":
154+
case "content-type":
155+
continue;
156+
default:
157+
return true;
158+
}
159+
}
160+
return false;
161+
}
162+
121163
private static @NonNull Bundle extractBundle(Map<String, String> headersMap) {
122164
final Bundle headersBundle = new Bundle();
123165
for (String key : headersMap.keySet()) {

packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
import static org.junit.Assert.assertEquals;
88
import static org.junit.Assert.assertFalse;
9+
import static org.junit.Assert.assertNull;
910
import static org.junit.Assert.assertThrows;
1011
import static org.junit.Assert.assertTrue;
1112
import static org.mockito.ArgumentMatchers.any;
13+
import static org.mockito.ArgumentMatchers.isNull;
1214
import static org.mockito.Mockito.doThrow;
1315
import static org.mockito.Mockito.mock;
1416
import static org.mockito.Mockito.verify;
@@ -128,21 +130,23 @@ public void launch_returnsTrue() {
128130
}
129131

130132
@Test
131-
public void openWebView_opensUrl() {
133+
public void openWebView_opensUrl_inWebView() {
132134
Activity activity = mock(Activity.class);
133135
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
134136
api.setActivity(activity);
135137
String url = "https://flutter.dev";
136138
boolean enableJavaScript = false;
137139
boolean enableDomStorage = false;
140+
HashMap<String, String> headers = new HashMap<>();
141+
headers.put("key", "value");
138142

139143
boolean result =
140144
api.openUrlInWebView(
141145
url,
142146
new Messages.WebViewOptions.Builder()
143147
.setEnableJavaScript(enableJavaScript)
144148
.setEnableDomStorage(enableDomStorage)
145-
.setHeaders(new HashMap<>())
149+
.setHeaders(headers)
146150
.build());
147151

148152
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@@ -157,19 +161,102 @@ public void openWebView_opensUrl() {
157161
intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA));
158162
}
159163

164+
@Test
165+
public void openWebView_opensUrl_inCustomTabs() {
166+
Activity activity = mock(Activity.class);
167+
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
168+
api.setActivity(activity);
169+
String url = "https://flutter.dev";
170+
171+
boolean result =
172+
api.openUrlInWebView(
173+
url,
174+
new Messages.WebViewOptions.Builder()
175+
.setEnableJavaScript(false)
176+
.setEnableDomStorage(false)
177+
.setHeaders(new HashMap<>())
178+
.build());
179+
180+
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
181+
verify(activity).startActivity(intentCaptor.capture(), isNull());
182+
assertTrue(result);
183+
assertEquals(Intent.ACTION_VIEW, intentCaptor.getValue().getAction());
184+
assertNull(intentCaptor.getValue().getComponent());
185+
}
186+
187+
@Test
188+
public void openWebView_opensUrl_inCustomTabs_withCORSAllowedHeader() {
189+
Activity activity = mock(Activity.class);
190+
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
191+
api.setActivity(activity);
192+
String url = "https://flutter.dev";
193+
HashMap<String, String> headers = new HashMap<>();
194+
String headerKey = "Content-Type";
195+
headers.put(headerKey, "text/plain");
196+
197+
boolean result =
198+
api.openUrlInWebView(
199+
url,
200+
new Messages.WebViewOptions.Builder()
201+
.setEnableJavaScript(false)
202+
.setEnableDomStorage(false)
203+
.setHeaders(headers)
204+
.build());
205+
206+
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
207+
verify(activity).startActivity(intentCaptor.capture(), isNull());
208+
assertTrue(result);
209+
assertEquals(Intent.ACTION_VIEW, intentCaptor.getValue().getAction());
210+
assertNull(intentCaptor.getValue().getComponent());
211+
final Bundle passedHeaders =
212+
intentCaptor.getValue().getExtras().getBundle(Browser.EXTRA_HEADERS);
213+
assertEquals(headers.get(headerKey), passedHeaders.getString(headerKey));
214+
}
215+
216+
@Test
217+
public void openWebView_fallsbackTo_inWebView() {
218+
Activity activity = mock(Activity.class);
219+
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
220+
api.setActivity(activity);
221+
String url = "https://flutter.dev";
222+
doThrow(new ActivityNotFoundException())
223+
.when(activity)
224+
.startActivity(any(), isNull()); // for custom tabs intent
225+
226+
boolean result =
227+
api.openUrlInWebView(
228+
url,
229+
new Messages.WebViewOptions.Builder()
230+
.setEnableJavaScript(false)
231+
.setEnableDomStorage(false)
232+
.setHeaders(new HashMap<>())
233+
.build());
234+
235+
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
236+
verify(activity).startActivity(intentCaptor.capture());
237+
assertTrue(result);
238+
assertEquals(url, intentCaptor.getValue().getExtras().getString(WebViewActivity.URL_EXTRA));
239+
assertEquals(
240+
false, intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_JS_EXTRA));
241+
assertEquals(
242+
false, intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA));
243+
}
244+
160245
@Test
161246
public void openWebView_handlesEnableJavaScript() {
162247
Activity activity = mock(Activity.class);
163248
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
164249
api.setActivity(activity);
165250
boolean enableJavaScript = true;
251+
HashMap<String, String> headers = new HashMap<>();
252+
headers.put("key", "value");
166253

167254
api.openUrlInWebView(
168255
"https://flutter.dev",
169256
new Messages.WebViewOptions.Builder()
170257
.setEnableJavaScript(enableJavaScript)
171258
.setEnableDomStorage(false)
172-
.setHeaders(new HashMap<>())
259+
.setHeaders(headers)
173260
.build());
174261

175262
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@@ -213,13 +300,15 @@ public void openWebView_handlesEnableDomStorage() {
213300
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
214301
api.setActivity(activity);
215302
boolean enableDomStorage = true;
303+
HashMap<String, String> headers = new HashMap<>();
304+
headers.put("key", "value");
216305

217306
api.openUrlInWebView(
218307
"https://flutter.dev",
219308
new Messages.WebViewOptions.Builder()
220309
.setEnableJavaScript(false)
221310
.setEnableDomStorage(enableDomStorage)
222-
.setHeaders(new HashMap<>())
311+
.setHeaders(headers)
223312
.build());
224313

225314
final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
@@ -253,7 +342,12 @@ public void openWebView_returnsFalse() {
253342
Activity activity = mock(Activity.class);
254343
UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext());
255344
api.setActivity(activity);
256-
doThrow(new ActivityNotFoundException()).when(activity).startActivity(any());
345+
doThrow(new ActivityNotFoundException())
346+
.when(activity)
347+
.startActivity(any(), isNull()); // for custom tabs intent
348+
doThrow(new ActivityNotFoundException())
349+
.when(activity)
350+
.startActivity(any()); // for webview intent
257351

258352
boolean result =
259353
api.openUrlInWebView(

packages/url_launcher/url_launcher_android/example/lib/main.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ class _MyHomePageState extends State<MyHomePage> {
6868
}
6969

7070
Future<void> _launchInWebView(String url) async {
71+
if (!await launcher.launch(
72+
url,
73+
useSafariVC: true,
74+
useWebView: true,
75+
enableJavaScript: false,
76+
enableDomStorage: false,
77+
universalLinksOnly: false,
78+
headers: <String, String>{},
79+
)) {
80+
throw Exception('Could not launch $url');
81+
}
82+
}
83+
84+
Future<void> _launchInWebViewWithCustomHeaders(String url) async {
7185
if (!await launcher.launch(
7286
url,
7387
useSafariVC: true,
@@ -185,6 +199,12 @@ class _MyHomePageState extends State<MyHomePage> {
185199
}),
186200
child: const Text('Launch in app'),
187201
),
202+
ElevatedButton(
203+
onPressed: () => setState(() {
204+
_launched = _launchInWebViewWithCustomHeaders(toLaunch);
205+
}),
206+
child: const Text('Launch in app (Custom headers)'),
207+
),
188208
ElevatedButton(
189209
onPressed: () => setState(() {
190210
_launched = _launchInWebViewWithJavaScript(toLaunch);

packages/url_launcher/url_launcher_android/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: url_launcher_android
22
description: Android implementation of the url_launcher plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
5-
version: 6.0.39
5+
version: 6.1.0
66
environment:
77
sdk: ">=2.19.0 <4.0.0"
88
flutter: ">=3.7.0"

packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.1.5
2+
3+
* Updates documentation to mention support for Android Custom Tabs.
4+
15
## 2.1.4
26

37
* Adds pub topics to package metadata.

packages/url_launcher/url_launcher_platform_interface/lib/src/types.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ enum PreferredLaunchMode {
1313
/// implementation.
1414
platformDefault,
1515

16-
/// Loads the URL in an in-app web view (e.g., Safari View Controller).
16+
/// Loads the URL in an in-app web view (e.g., Android Custom Tabs, Safari View Controller).
1717
inAppWebView,
1818

1919
/// Passes the URL to the OS to be handled by another application.

packages/url_launcher/url_launcher_platform_interface/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
55
# NOTE: We strongly prefer non-breaking changes, even at the expense of a
66
# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
7-
version: 2.1.4
7+
version: 2.1.5
88

99
environment:
1010
sdk: ">=2.19.0 <4.0.0"

0 commit comments

Comments
 (0)