From 1c0ba3a9f61171f098967c16c1ca4e84403ddd23 Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Wed, 23 Aug 2023 20:58:32 +0530 Subject: [PATCH] [url_launcher_android] Add support for Custom Tabs --- .../url_launcher/url_launcher/CHANGELOG.md | 3 +- .../url_launcher/example/lib/main.dart | 14 +++- .../url_launcher/lib/src/types.dart | 2 +- .../url_launcher/url_launcher/pubspec.yaml | 2 +- .../url_launcher_android/CHANGELOG.md | 3 +- .../url_launcher_android/android/build.gradle | 1 + .../plugins/urllauncher/UrlLauncher.java | 50 ++++++++++++- .../plugins/urllauncher/UrlLauncherTest.java | 75 +++++++++++++++++-- .../example/lib/main.dart | 20 +++++ .../url_launcher_android/pubspec.yaml | 2 +- .../CHANGELOG.md | 3 +- .../lib/src/types.dart | 2 +- .../pubspec.yaml | 2 +- 13 files changed, 161 insertions(+), 18 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index ef7f05c3dca1..9595660864a6 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 6.1.13 +* Updates documentation to talk about support for Android Custom Tabs. * Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 6.1.12 diff --git a/packages/url_launcher/url_launcher/example/lib/main.dart b/packages/url_launcher/url_launcher/example/lib/main.dart index f88fcfeb9150..4e1a41e6c566 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 _launchInWebViewOrVCWithCustomHeaders(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 = _launchInWebViewOrVCWithCustomHeaders(toLaunch); + }), + child: const Text('Launch in app (Custom Headers)'), + ), ElevatedButton( onPressed: () => setState(() { _launched = _launchInWebViewWithoutJavaScript(toLaunch); @@ -194,7 +206,7 @@ class _MyHomePageState extends State { const Padding(padding: EdgeInsets.all(16.0)), ElevatedButton( onPressed: () => setState(() { - _launched = _launchInWebViewOrVC(toLaunch); + _launched = _launchInWebViewOrVCWithCustomHeaders(toLaunch); Timer(const Duration(seconds: 5), () { closeInAppWebView(); }); 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 78d9723ddd70..a09f28943c89 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.12 +version: 6.1.13 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 7d9539721a16..1e6ccf323698 100644 --- a/packages/url_launcher/url_launcher_android/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 6.1.0 +* Adds support for Android Custom Tabs * Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 6.0.38 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..36dfa3c07ea2 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. */ @@ -46,12 +48,12 @@ interface IntentResolver { } UrlLauncher(@NonNull Context context) { - this( - context, + this.applicationContext = context; + this.intentResolver = intent -> { ComponentName componentName = intent.resolveActivity(context.getPackageManager()); return componentName == null ? null : componentName.toShortString(); - }); + }; } void setActivity(@Nullable Activity activity) { @@ -97,13 +99,21 @@ void setActivity(@Nullable Activity activity) { ensureActivity(); assert activity != null; + Uri uri = Uri.parse(url); + Bundle headersBundle = extractBundle(options.getHeaders()); + if (!containsRestrictedHeader(options.getHeaders())) { + if (openCustomTab(activity, uri, headersBundle)) { + return true; + } + } + Intent launchIntent = WebViewActivity.createIntent( activity, url, options.getEnableJavaScript(), options.getEnableDomStorage(), - extractBundle(options.getHeaders())); + headersBundle); try { activity.startActivity(launchIntent); } catch (ActivityNotFoundException e) { @@ -118,6 +128,38 @@ public void closeWebView() { applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE)); } + private static @NonNull 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 @NonNull Boolean containsRestrictedHeader(Map headersMap) { + for (String key : headersMap.keySet()) { + switch (key.toLowerCase(Locale.US)) { + case "accept": + continue; + case "accept-language": + continue; + case "content-language": + continue; + 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..5435d09c3831 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,73 @@ 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_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 +271,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 +313,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 f418413be93d..5b065ea6fbb7 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.38 +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 d440d1390faf..53e327f92dd5 100644 --- a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.1.4 +* Updates documentation to talk about support for Android Custom Tabs. * Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 2.1.3 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 bc5e6cbbb409..22d78079ba7a 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.3 +version: 2.1.4 environment: sdk: ">=2.19.0 <4.0.0"