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_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md index 7d9539721a16..27bfd61df5b5 100644 --- a/packages/url_launcher/url_launcher_android/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 6.0.39 * Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. +* Add support for Android Custom Tabs ## 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..8764d0f13b83 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,6 +16,7 @@ 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.Map; @@ -46,12 +47,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 +98,23 @@ void setActivity(@Nullable Activity activity) { ensureActivity(); assert activity != null; + Uri uri = Uri.parse(url); + Bundle headersBundle = extractBundle(options.getHeaders()); + if (!containsRestrictedHeader(options.getHeaders()) + && options.getEnableJavaScript() + && options.getEnableDomStorage()) { + 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 +129,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()) { + 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..b25dce1662c2 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,7 +130,7 @@ 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); @@ -157,6 +159,61 @@ 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 enableJavaScript = true; + boolean enableDomStorage = true; + + boolean result = + api.openUrlInWebView( + url, + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(enableJavaScript) + .setEnableDomStorage(enableDomStorage) + .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"; + boolean enableJavaScript = true; + boolean enableDomStorage = true; + HashMap headers = new HashMap<>(); + String headerKey = "Content-Type"; + headers.put(headerKey, "text/plain"); + + boolean result = + api.openUrlInWebView( + url, + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(enableJavaScript) + .setEnableDomStorage(enableDomStorage) + .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); 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..defa02a467a0 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: true, + enableDomStorage: true, + 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..f0e114317d28 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.0.39 environment: sdk: ">=2.19.0 <4.0.0" flutter: ">=3.7.0"