001/*
002 * Copyright 2015 The AppAuth for Android Authors. All Rights Reserved.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
005 * in compliance with the License. You may obtain a copy of the License at
006 *
007 * http://www.apache.org/licenses/LICENSE-2.0
008 *
009 * Unless required by applicable law or agreed to in writing, software distributed under the
010 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
011 * express or implied. See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014
015package net.openid.appauth.browser;
016
017import android.annotation.SuppressLint;
018import android.content.Context;
019import android.content.Intent;
020import android.content.pm.PackageInfo;
021import android.content.pm.PackageManager;
022import android.content.pm.PackageManager.NameNotFoundException;
023import android.content.pm.ResolveInfo;
024import android.net.Uri;
025import android.os.Build.VERSION;
026import android.os.Build.VERSION_CODES;
027import androidx.annotation.NonNull;
028import androidx.annotation.Nullable;
029import androidx.annotation.VisibleForTesting;
030import androidx.browser.customtabs.CustomTabsService;
031
032import java.util.ArrayList;
033import java.util.Iterator;
034import java.util.List;
035
036/**
037 * Utility class to obtain the browser package name to be used for
038 * {@link net.openid.appauth.AuthorizationService#performAuthorizationRequest(
039 * net.openid.appauth.AuthorizationRequest,
040 * android.app.PendingIntent)} calls. It prioritizes browsers which support
041 * [custom tabs](https://developer.chrome.com/multidevice/android/customtabs). To mitigate
042 * man-in-the-middle attacks by malicious apps pretending to be browsers for the specific URI we
043 * query, only those which are registered as a handler for _all_ HTTP and HTTPS URIs will be
044 * used.
045 */
046public final class BrowserSelector {
047
048    private static final String SCHEME_HTTP = "http";
049    private static final String SCHEME_HTTPS = "https";
050
051    /**
052     * The service we expect to find on a web browser that indicates it supports custom tabs.
053     */
054    @VisibleForTesting
055    static final String ACTION_CUSTOM_TABS_CONNECTION =
056            CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION;
057
058    /**
059     * Intent for querying installed web browsers as seen at
060     * https://cs.android.com/android/platform/superproject/+/master:packages/modules/Permission/PermissionController/src/com/android/permissioncontroller/role/model/BrowserRoleBehavior.java
061     */
062    @VisibleForTesting
063    static final Intent BROWSER_INTENT = new Intent()
064            .setAction(Intent.ACTION_VIEW)
065            .addCategory(Intent.CATEGORY_BROWSABLE)
066            .setData(Uri.fromParts("http", "", null));
067
068    /**
069     * Retrieves the full list of browsers installed on the device. Two entries will exist
070     * for each browser that supports custom tabs, with the {@link BrowserDescriptor#useCustomTab}
071     * flag set to `true` in one and `false` in the other. The list is in the
072     * order returned by the package manager, so indirectly reflects the user's preferences
073     * (i.e. their default browser, if set, should be the first entry in the list).
074     */
075    @SuppressLint("PackageManagerGetSignatures")
076    @NonNull
077    public static List<BrowserDescriptor> getAllBrowsers(Context context) {
078        PackageManager pm = context.getPackageManager();
079        List<BrowserDescriptor> browsers = new ArrayList<>();
080        String defaultBrowserPackage = null;
081
082        int queryFlag = PackageManager.GET_RESOLVED_FILTER;
083        if (VERSION.SDK_INT >= VERSION_CODES.M) {
084            queryFlag |= PackageManager.MATCH_ALL;
085        }
086        // When requesting all matching activities for an intent from the package manager,
087        // the user's preferred browser is not guaranteed to be at the head of this list.
088        // Therefore, the preferred browser must be separately determined and the resultant
089        // list of browsers reordered to restored this desired property.
090        ResolveInfo resolvedDefaultActivity =
091                pm.resolveActivity(BROWSER_INTENT, 0);
092        if (resolvedDefaultActivity != null) {
093            defaultBrowserPackage = resolvedDefaultActivity.activityInfo.packageName;
094        }
095        List<ResolveInfo> resolvedActivityList =
096                pm.queryIntentActivities(BROWSER_INTENT, queryFlag);
097
098        for (ResolveInfo info : resolvedActivityList) {
099            // ignore handlers which are not browsers
100            if (!isFullBrowser(info)) {
101                continue;
102            }
103
104            try {
105                int defaultBrowserIndex = 0;
106                PackageInfo packageInfo = pm.getPackageInfo(
107                        info.activityInfo.packageName,
108                        PackageManager.GET_SIGNATURES);
109
110                if (hasWarmupService(pm, info.activityInfo.packageName)) {
111                    BrowserDescriptor customTabBrowserDescriptor =
112                            new BrowserDescriptor(packageInfo, true);
113                    if (info.activityInfo.packageName.equals(defaultBrowserPackage)) {
114                        // If the default browser is having a WarmupService,
115                        // will it be added to the beginning of the list.
116                        browsers.add(defaultBrowserIndex, customTabBrowserDescriptor);
117                        defaultBrowserIndex++;
118                    } else {
119                        browsers.add(customTabBrowserDescriptor);
120                    }
121                }
122
123                BrowserDescriptor fullBrowserDescriptor =
124                        new BrowserDescriptor(packageInfo, false);
125                if (info.activityInfo.packageName.equals(defaultBrowserPackage)) {
126                    // The default browser is added to the beginning of the list.
127                    // If there is support for Custom Tabs, will the one disabling Custom Tabs
128                    // be added as the second entry.
129                    browsers.add(defaultBrowserIndex, fullBrowserDescriptor);
130                } else {
131                    browsers.add(fullBrowserDescriptor);
132                }
133            } catch (NameNotFoundException e) {
134                // a descriptor cannot be generated without the package info
135            }
136        }
137
138        return browsers;
139    }
140
141    /**
142     * Searches through all browsers for the best match based on the supplied browser matcher.
143     * Custom tab supporting browsers are preferred, if the matcher permits them, and browsers
144     * are evaluated in the order returned by the package manager, which should indirectly match
145     * the user's preferences.
146     *
147     * @param context {@link Context} to use for accessing {@link PackageManager}.
148     * @return The package name recommended to use for connecting to custom tabs related components.
149     */
150    @SuppressLint("PackageManagerGetSignatures")
151    @Nullable
152    public static BrowserDescriptor select(Context context, BrowserMatcher browserMatcher) {
153        List<BrowserDescriptor> allBrowsers = getAllBrowsers(context);
154        BrowserDescriptor bestMatch = null;
155        for (BrowserDescriptor browser : allBrowsers) {
156            if (!browserMatcher.matches(browser)) {
157                continue;
158            }
159
160            if (browser.useCustomTab) {
161                // directly return the first custom tab supporting browser that is matched
162                return browser;
163            }
164
165            if (bestMatch == null) {
166                // store this as the best match for use if we don't find any matching
167                // custom tab supporting browsers
168                bestMatch = browser;
169            }
170        }
171
172        return bestMatch;
173    }
174
175    private static boolean hasWarmupService(PackageManager pm, String packageName) {
176        Intent serviceIntent = new Intent();
177        serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION);
178        serviceIntent.setPackage(packageName);
179        return (pm.resolveService(serviceIntent, 0) != null);
180    }
181
182    private static boolean isFullBrowser(ResolveInfo resolveInfo) {
183        // The filter must match ACTION_VIEW, CATEGORY_BROWSEABLE, and at least one scheme,
184        if (resolveInfo.filter == null
185                || !resolveInfo.filter.hasAction(Intent.ACTION_VIEW)
186                || !resolveInfo.filter.hasCategory(Intent.CATEGORY_BROWSABLE)
187                || resolveInfo.filter.schemesIterator() == null) {
188            return false;
189        }
190
191        // The filter must not be restricted to any particular set of authorities
192        if (resolveInfo.filter.authoritiesIterator() != null) {
193            return false;
194        }
195
196        // The filter must support both HTTP and HTTPS.
197        boolean supportsHttp = false;
198        boolean supportsHttps = false;
199        Iterator<String> schemeIter = resolveInfo.filter.schemesIterator();
200        while (schemeIter.hasNext()) {
201            String scheme = schemeIter.next();
202            supportsHttp |= SCHEME_HTTP.equals(scheme);
203            supportsHttps |= SCHEME_HTTPS.equals(scheme);
204
205            if (supportsHttp && supportsHttps) {
206                return true;
207            }
208        }
209
210        // at least one of HTTP or HTTPS is not supported
211        return false;
212    }
213}