From e668c436dc331add62e8e37edc4d5013e9476e72 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Sep 2023 00:46:04 +0530 Subject: [PATCH] [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 --- .../url_launcher/url_launcher/CHANGELOG.md | 4 + .../url_launcher/example/lib/main.dart | 12 ++ .../url_launcher/lib/src/types.dart | 2 +- .../url_launcher/url_launcher/pubspec.yaml | 2 +- .../url_launcher_android/CHANGELOG.md | 4 + .../url_launcher_android/android/build.gradle | 1 + .../plugins/urllauncher/UrlLauncher.java | 44 +++++++- .../plugins/urllauncher/UrlLauncherTest.java | 104 +++++++++++++++++- .../example/lib/main.dart | 20 ++++ .../url_launcher_android/pubspec.yaml | 2 +- .../CHANGELOG.md | 4 + .../lib/src/types.dart | 2 +- .../pubspec.yaml | 2 +- 13 files changed, 192 insertions(+), 11 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 75b3d6fbeacf..cebc8177291b 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 f88fcfeb9150..57a0ce9ef470 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 @@ class _MyHomePageState extends State { } Future _launchInWebViewOrVC(Uri url) async { + if (!await launchUrl(url, mode: LaunchMode.inAppWebView)) { + throw Exception('Could not launch $url'); + } + } + + Future _launchAsInAppWebViewWithCustomHeaders(Uri url) async { if (!await launchUrl( url, mode: LaunchMode.inAppWebView, @@ -171,6 +177,12 @@ class _MyHomePageState extends State { }), 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); diff --git a/packages/url_launcher/url_launcher/lib/src/types.dart b/packages/url_launcher/url_launcher/lib/src/types.dart index bcfcb7887b17..359e293ef82e 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 @@ 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. diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 44f01a4f250d..9feae723f2f1 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -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" diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md index beda53794ef7..ed8a88314da1 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 2ddd182523fc..dad5ba7507c6 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 @@ 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' 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 bb280a8e6d76..8ee9bffbb587 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 @@ 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) { @@ -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 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 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 e87def079f27..b8bb3b4f21ef 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 @@ 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 headers = new HashMap<>(); + headers.put("key", "value"); boolean result = api.openUrlInWebView( @@ -142,7 +146,7 @@ public void openWebView_opensUrl() { new Messages.WebViewOptions.Builder() .setEnableJavaScript(enableJavaScript) .setEnableDomStorage(enableDomStorage) - .setHeaders(new HashMap<>()) + .setHeaders(headers) .build()); final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); @@ -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 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 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 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 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 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 intentCaptor = ArgumentCaptor.forClass(Intent.class); @@ -213,13 +300,15 @@ public void openWebView_handlesEnableDomStorage() { UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); api.setActivity(activity); boolean enableDomStorage = true; + HashMap 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 intentCaptor = ArgumentCaptor.forClass(Intent.class); @@ -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( 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 d10681bdc8e1..df28069ca1de 100644 --- a/packages/url_launcher/url_launcher_android/example/lib/main.dart +++ b/packages/url_launcher/url_launcher_android/example/lib/main.dart @@ -68,6 +68,20 @@ class _MyHomePageState extends State { } Future _launchInWebView(String url) async { + if (!await launcher.launch( + url, + useSafariVC: true, + useWebView: true, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: {}, + )) { + throw Exception('Could not launch $url'); + } + } + + Future _launchInWebViewWithCustomHeaders(String url) async { if (!await launcher.launch( url, useSafariVC: true, @@ -185,6 +199,12 @@ class _MyHomePageState extends State { }), 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); diff --git a/packages/url_launcher/url_launcher_android/pubspec.yaml b/packages/url_launcher/url_launcher_android/pubspec.yaml index 9ac8dbbc7eea..f5db21c93b4a 100644 --- a/packages/url_launcher/url_launcher_android/pubspec.yaml +++ b/packages/url_launcher/url_launcher_android/pubspec.yaml @@ -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" diff --git a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md index 2a9da456ead9..f5523ced4717 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 08d87e03a128..ca9d8e1c9175 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 @@ 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. diff --git a/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml b/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml index c4dd9c35fd0e..5aa135fbc315 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 @@ 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"