Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

[url_launcher] Add non-browser URL launch mode for Android #5953

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/url_launcher/url_launcher/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change will need to be a separate PR, since you need to update the minimum Android implementation package version to say this.

/// non-browser application.
///
/// For web, [webOnlyWindowName] specifies a target for the launch. This
/// supports the standard special link target names. For example:
Expand Down
2 changes: 1 addition & 1 deletion packages/url_launcher/url_launcher/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/url_launcher/url_launcher_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}
Expand All @@ -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<String> nonBrowserPackageNames = getNonBrowserPackageNames(launchIntent);
if (nonBrowserPackageNames.isEmpty()) {
return LaunchStatus.ACTIVITY_NOT_FOUND;
}
}
}
}

try {
Expand All @@ -95,6 +116,31 @@ LaunchStatus launch(
return LaunchStatus.OK;
}

private Set<String> getNonBrowserPackageNames(Intent specializedIntent) {
PackageManager packageManager = applicationContext.getPackageManager();

// Get all apps that resolve the specific URL.
Set<String> 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<String> genericPackageNames = queryPackageNames(packageManager, genericIntent);

// Keep only the apps that resolve the specific, but not the generic URLs.
specializedPackageNames.removeAll(genericPackageNames);
return specializedPackageNames;
}

private Set<String> queryPackageNames(PackageManager packageManager, Intent intent) {
List<ResolveInfo> intentActivities = packageManager.queryIntentActivities(intent, 0);
Set<String> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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);
Expand All @@ -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<String, Object> 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);
Expand All @@ -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<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Intent> {
public IntentDataMatcher(String dataString) {
this.dataString = dataString;
}

private final String dataString;

@Override
public boolean matches(Object intent) {
return ((Intent) intent).getDataString().equals(dataString);
}
}
Original file line number Diff line number Diff line change
@@ -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 <T> void setFinalStatic(Class<T> 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);
}
}
}
2 changes: 1 addition & 1 deletion packages/url_launcher/url_launcher_android/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down