diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 182119345ccc..9a38b855eee0 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,6 +1,8 @@ -## NEXT +## 6.1.4 * Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/105648). +* Updates documentation to mention `LaunchMode.externalNonBrowserApplication` + now being supported for Android in `launchUrl`. ## 6.1.3 diff --git a/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart b/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart index fc33f05e5afb..47b6a8af27c2 100644 --- a/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart +++ b/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart @@ -25,9 +25,9 @@ import 'package:url_launcher_platform_interface/url_launcher_platform_interface. /// On iOS, this should be used in cases where sharing the cookies of the /// user's browser is important, such as SSO flows, since Safari View /// Controller does not share the browser's context. -/// - [LaunchMode.externalNonBrowserApplication] is supported on iOS 10+. -/// This setting is used to require universal links to open in a non-browser -/// application. +/// - [LaunchMode.externalNonBrowserApplication] is supported on iOS 10+ and +/// Android. This setting is used to require universal links to open in a +/// non-browser application. /// /// For web, [webOnlyWindowName] specifies a target for the launch. This /// supports the standard special link target names. For example: diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 2a7ddcdb1dbe..9a93b2080df6 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/plugins/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.3 +version: 6.1.4 environment: sdk: ">=2.14.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md index 887178c479e4..890053edae79 100644 --- a/packages/url_launcher/url_launcher_android/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.18 + +* Adds support for requiring links to open in a non-browser app in `launch` + through the `universalLinksOnly` parameter. + ## 6.0.17 * Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java index f7bed8648872..c3d67feed613 100644 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java @@ -88,11 +88,13 @@ private void onLaunch(MethodCall call, Result result, String url) { final boolean useWebView = call.argument("useWebView"); final boolean enableJavaScript = call.argument("enableJavaScript"); final boolean enableDomStorage = call.argument("enableDomStorage"); + final boolean universalLinksOnly = call.argument("universalLinksOnly"); final Map headersMap = call.argument("headers"); final Bundle headersBundle = extractBundle(headersMap); LaunchStatus launchStatus = - urlLauncher.launch(url, headersBundle, useWebView, enableJavaScript, enableDomStorage); + urlLauncher.launch( + url, headersBundle, useWebView, enableJavaScript, enableDomStorage, universalLinksOnly); if (launchStatus == LaunchStatus.NO_ACTIVITY) { result.error("NO_ACTIVITY", "Launching a URL requires a foreground activity.", null); 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 c3a563a9c137..b012b9b59bfb 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 @@ -9,11 +9,17 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.Browser; import android.util.Log; import androidx.annotation.Nullable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; /** Launches components for URLs. */ class UrlLauncher { @@ -60,6 +66,9 @@ boolean canLaunch(String url) { * @param useWebView when true, the URL is launched inside of {@link WebViewActivity}. * @param enableJavaScript Only used if {@param useWebView} is true. Enables JS in the WebView. * @param enableDomStorage Only used if {@param useWebView} is true. Enables DOM storage in the + * WebView. + * @param universalLinksOnly Only used if {@param useWebView} is false. When true, will only + * launch if an app is available that is not a browser. * @return {@link LaunchStatus#NO_ACTIVITY} if there's no available {@code applicationContext}. * {@link LaunchStatus#ACTIVITY_NOT_FOUND} if there's no activity found to handle {@code * launchIntent}. {@link LaunchStatus#OK} otherwise. @@ -69,7 +78,8 @@ LaunchStatus launch( Bundle headersBundle, boolean useWebView, boolean enableJavaScript, - boolean enableDomStorage) { + boolean enableDomStorage, + boolean universalLinksOnly) { if (activity == null) { return LaunchStatus.NO_ACTIVITY; } @@ -84,6 +94,17 @@ LaunchStatus launch( new Intent(Intent.ACTION_VIEW) .setData(Uri.parse(url)) .putExtra(Browser.EXTRA_HEADERS, headersBundle); + + if (universalLinksOnly) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + launchIntent = launchIntent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER); + } else { + Set nonBrowserPackageNames = getNonBrowserPackageNames(launchIntent); + if (nonBrowserPackageNames.isEmpty()) { + return LaunchStatus.ACTIVITY_NOT_FOUND; + } + } + } } try { @@ -95,6 +116,31 @@ LaunchStatus launch( return LaunchStatus.OK; } + private Set getNonBrowserPackageNames(Intent specializedIntent) { + PackageManager packageManager = applicationContext.getPackageManager(); + + // Get all apps that resolve the specific URL. + Set specializedPackageNames = queryPackageNames(packageManager, specializedIntent); + + // Get all apps that resolve a generic URL. + Intent genericIntent = + new Intent().setAction(Intent.ACTION_VIEW).setData(Uri.fromParts("https", "", null)); + Set genericPackageNames = queryPackageNames(packageManager, genericIntent); + + // Keep only the apps that resolve the specific, but not the generic URLs. + specializedPackageNames.removeAll(genericPackageNames); + return specializedPackageNames; + } + + private Set queryPackageNames(PackageManager packageManager, Intent intent) { + List intentActivities = packageManager.queryIntentActivities(intent, 0); + Set packageNames = new HashSet<>(); + for (ResolveInfo intentActivity : intentActivities) { + packageNames.add(intentActivity.activityInfo.packageName); + } + return packageNames; + } + /** Closes any activities started with {@link #launch} {@code useWebView=true}. */ void closeWebView() { applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE)); diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java index b60192531dbd..74524a53ce6e 100644 --- a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java @@ -120,16 +120,23 @@ public void onMethodCall_launchReturnsNoActivityError() { boolean useWebView = false; boolean enableJavaScript = false; boolean enableDomStorage = false; + boolean universalLinksOnly = false; // Setup arguments map send on the method channel Map args = new HashMap<>(); args.put("url", url); args.put("useWebView", useWebView); args.put("enableJavaScript", enableJavaScript); args.put("enableDomStorage", enableDomStorage); + args.put("universalLinksOnly", universalLinksOnly); args.put("headers", new HashMap<>()); // Mock the launch method on the urlLauncher class when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) + eq(url), + any(Bundle.class), + eq(useWebView), + eq(enableJavaScript), + eq(enableDomStorage), + eq(universalLinksOnly))) .thenReturn(UrlLauncher.LaunchStatus.NO_ACTIVITY); // Act by calling the "launch" method on the method channel methodCallHandler = new MethodCallHandlerImpl(urlLauncher); @@ -149,16 +156,23 @@ public void onMethodCall_launchReturnsActivityNotFoundError() { boolean useWebView = false; boolean enableJavaScript = false; boolean enableDomStorage = false; + boolean universalLinksOnly = false; // Setup arguments map send on the method channel Map args = new HashMap<>(); args.put("url", url); args.put("useWebView", useWebView); args.put("enableJavaScript", enableJavaScript); args.put("enableDomStorage", enableDomStorage); + args.put("universalLinksOnly", universalLinksOnly); args.put("headers", new HashMap<>()); // Mock the launch method on the urlLauncher class when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) + eq(url), + any(Bundle.class), + eq(useWebView), + eq(enableJavaScript), + eq(enableDomStorage), + eq(universalLinksOnly))) .thenReturn(UrlLauncher.LaunchStatus.ACTIVITY_NOT_FOUND); // Act by calling the "launch" method on the method channel methodCallHandler = new MethodCallHandlerImpl(urlLauncher); @@ -181,16 +195,23 @@ public void onMethodCall_launchReturnsTrue() { boolean useWebView = false; boolean enableJavaScript = false; boolean enableDomStorage = false; + boolean universalLinksOnly = false; // Setup arguments map send on the method channel Map args = new HashMap<>(); args.put("url", url); args.put("useWebView", useWebView); args.put("enableJavaScript", enableJavaScript); args.put("enableDomStorage", enableDomStorage); + args.put("universalLinksOnly", universalLinksOnly); args.put("headers", new HashMap<>()); // Mock the launch method on the urlLauncher class when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) + eq(url), + any(Bundle.class), + eq(useWebView), + eq(enableJavaScript), + eq(enableDomStorage), + eq(universalLinksOnly))) .thenReturn(UrlLauncher.LaunchStatus.OK); // Act by calling the "launch" method on the method channel methodCallHandler = new MethodCallHandlerImpl(urlLauncher); 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 new file mode 100644 index 000000000000..85f935b9ab57 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.urllauncher; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import io.flutter.plugins.urllauncher.utils.IntentDataMatcher; +import io.flutter.plugins.urllauncher.utils.TestUtils; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class UrlLauncherTest { + private Context applicationContext; + private UrlLauncher urlLauncher; + + private static final String LAUNCH_URL = "https://www.google.com"; + + @Before + public void setUp() { + applicationContext = mock(Context.class); + Activity activity = mock(Activity.class); + urlLauncher = new UrlLauncher(applicationContext, activity); + } + + @Test + public void launch_shouldNotQueryPackageManagerWhenUniversalLinksOnlyOnAndroidR() { + updateSdkVersion(Build.VERSION_CODES.R); + + try { + PackageManager mockPackageManager = mock(PackageManager.class); + when(applicationContext.getPackageManager()).thenReturn(mockPackageManager); + + urlLauncher.launch(LAUNCH_URL, null, false, false, false, true); + + verify(mockPackageManager, never()).queryIntentActivities(any(Intent.class), anyInt()); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void launch_shouldReturnOkWhenUniversalLinksOnlyBelowAndroidRAndNonBrowserPresent() { + updateSdkVersion(Build.VERSION_CODES.Q); + + try { + PackageManager mockPackageManager = mock(PackageManager.class); + when(applicationContext.getPackageManager()).thenReturn(mockPackageManager); + ResolveInfo browserIntentActivity = stubResolveInfo("browser"); + ResolveInfo nonBrowserIntentActivity = stubResolveInfo("nonBrowser"); + when(mockPackageManager.queryIntentActivities(any(Intent.class), anyInt())) + .thenReturn(Collections.singletonList(browserIntentActivity)); + when(mockPackageManager.queryIntentActivities( + Matchers.argThat(new IntentDataMatcher(LAUNCH_URL)), anyInt())) + .thenReturn(Arrays.asList(nonBrowserIntentActivity, browserIntentActivity)); + + UrlLauncher.LaunchStatus launchStatus = + urlLauncher.launch(LAUNCH_URL, null, false, false, false, true); + + verify(mockPackageManager, times(2)).queryIntentActivities(any(Intent.class), anyInt()); + assertEquals(launchStatus, UrlLauncher.LaunchStatus.OK); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + launch_shouldReturnActivityNotFoundWhenUniversalLinksOnlyBelowAndroidRAndNonBrowserNotPresent() { + updateSdkVersion(Build.VERSION_CODES.Q); + + try { + PackageManager mockPackageManager = mock(PackageManager.class); + when(applicationContext.getPackageManager()).thenReturn(mockPackageManager); + ResolveInfo browserIntentActivity = stubResolveInfo("browser"); + when(mockPackageManager.queryIntentActivities(any(Intent.class), anyInt())) + .thenReturn(Collections.singletonList(browserIntentActivity)); + + UrlLauncher.LaunchStatus launchStatus = + urlLauncher.launch(LAUNCH_URL, null, false, false, false, true); + + verify(mockPackageManager, times(2)).queryIntentActivities(any(Intent.class), anyInt()); + assertEquals(launchStatus, UrlLauncher.LaunchStatus.ACTIVITY_NOT_FOUND); + } finally { + updateSdkVersion(0); + } + } + + private static ResolveInfo stubResolveInfo(String packageName) { + ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = new ActivityInfo(); + resolveInfo.activityInfo.packageName = packageName; + return resolveInfo; + } + + private static void updateSdkVersion(int version) { + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", version); + } +} diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/IntentDataMatcher.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/IntentDataMatcher.java new file mode 100644 index 000000000000..21fa5bbadeb0 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/IntentDataMatcher.java @@ -0,0 +1,21 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.urllauncher.utils; + +import android.content.Intent; +import org.mockito.ArgumentMatcher; + +public class IntentDataMatcher extends ArgumentMatcher { + public IntentDataMatcher(String dataString) { + this.dataString = dataString; + } + + private final String dataString; + + @Override + public boolean matches(Object intent) { + return ((Intent) intent).getDataString().equals(dataString); + } +} diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/TestUtils.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/TestUtils.java new file mode 100644 index 000000000000..f67707b362d3 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/utils/TestUtils.java @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.urllauncher.utils; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import org.junit.Assert; + +public class TestUtils { + public static void setFinalStatic(Class classToModify, String fieldName, Object newValue) { + try { + Field field = classToModify.getField(fieldName); + field.setAccessible(true); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + + field.set(null, newValue); + } catch (Exception e) { + Assert.fail("Unable to mock static field: " + fieldName); + } + } +} diff --git a/packages/url_launcher/url_launcher_android/pubspec.yaml b/packages/url_launcher/url_launcher_android/pubspec.yaml index 3c80170f1422..040ba9917d7f 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/plugins/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.17 +version: 6.0.18 environment: sdk: ">=2.14.0 <3.0.0"