1 // Copyright 2020 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.weblayer_private;
6 
7 import android.Manifest.permission;
8 import android.app.Activity;
9 import android.content.Context;
10 import android.content.Intent;
11 import android.content.IntentFilter;
12 import android.content.pm.PackageManager;
13 import android.content.pm.ResolveInfo;
14 import android.net.Uri;
15 import android.os.Build;
16 import android.os.StrictMode;
17 import android.provider.Browser;
18 import android.provider.Telephony;
19 import android.text.TextUtils;
20 import android.webkit.MimeTypeMap;
21 
22 import androidx.annotation.VisibleForTesting;
23 
24 import org.chromium.base.ContextUtils;
25 import org.chromium.base.IntentUtils;
26 import org.chromium.base.PackageManagerUtils;
27 import org.chromium.base.PathUtils;
28 import org.chromium.base.metrics.RecordUserAction;
29 import org.chromium.base.task.PostTask;
30 import org.chromium.components.embedder_support.util.UrlConstants;
31 import org.chromium.components.embedder_support.util.UrlUtilitiesJni;
32 import org.chromium.components.external_intents.ExternalNavigationDelegate;
33 import org.chromium.components.external_intents.ExternalNavigationHandler.OverrideUrlLoadingResult;
34 import org.chromium.components.external_intents.ExternalNavigationParams;
35 import org.chromium.content_public.browser.LoadUrlParams;
36 import org.chromium.content_public.browser.NavigationController;
37 import org.chromium.content_public.browser.NavigationEntry;
38 import org.chromium.content_public.browser.UiThreadTaskTraits;
39 import org.chromium.content_public.common.Referrer;
40 import org.chromium.network.mojom.ReferrerPolicy;
41 import org.chromium.ui.base.PageTransition;
42 import org.chromium.ui.base.PermissionCallback;
43 
44 import java.util.ArrayList;
45 import java.util.Iterator;
46 import java.util.List;
47 
48 /**
49  * WebLayer's implementation of the {@link ExternalNavigationDelegate}.
50  */
51 public class ExternalNavigationDelegateImpl implements ExternalNavigationDelegate {
52     private static final String PDF_VIEWER = "com.google.android.apps.docs";
53     private static final String PDF_MIME = "application/pdf";
54     private static final String PDF_SUFFIX = ".pdf";
55     private static final String PDF_EXTENSION = "pdf";
56 
57     protected final Context mApplicationContext;
58     private final TabImpl mTab;
59     private boolean mTabDestroyed;
60 
61     // TODO(crbug.com/1031465): Componentize IntentHandler's constant to dedupe this.
62     private static final String ANDROID_APP_REFERRER_SCHEME = "android-app";
63     // TODO(crbug.com/1031465): Componentize IntentHandler's constant to dedupe this.
64     /**
65      * Records package names of other applications in the system that could have handled
66      * this intent.
67      */
68     public static final String EXTRA_EXTERNAL_NAV_PACKAGES = "org.chromium.chrome.browser.eenp";
69 
ExternalNavigationDelegateImpl(TabImpl tab)70     public ExternalNavigationDelegateImpl(TabImpl tab) {
71         mTab = tab;
72         mApplicationContext = ContextUtils.getApplicationContext();
73     }
74 
onTabDestroyed()75     public void onTabDestroyed() {
76         mTabDestroyed = true;
77     }
78 
79     /**
80      * Get a {@link Context} linked to this delegate with preference to {@link Activity}.
81      * The tab this delegate associates with can swap the {@link Activity} it is hosted in and
82      * during the swap, there might not be an available {@link Activity}.
83      * @return The activity {@link Context} if it can be reached.
84      *         Application {@link Context} if not.
85      */
getAvailableContext()86     protected final Context getAvailableContext() {
87         if (mTab.getBrowser().getContext() == null) return mApplicationContext;
88         Context activityContext = ContextUtils.activityFromContext(mTab.getBrowser().getContext());
89         if (activityContext == null) return mApplicationContext;
90         return activityContext;
91     }
92 
93     /**
94      * If the intent is for a pdf, resolves intent handlers to find the platform pdf viewer if
95      * it is available and force is for the provided |intent| so that the user doesn't need to
96      * choose it from Intent picker.
97      *
98      * @param intent Intent to open.
99      */
forcePdfViewerAsIntentHandlerIfNeeded(Intent intent)100     public static void forcePdfViewerAsIntentHandlerIfNeeded(Intent intent) {
101         if (intent == null || !isPdfIntent(intent)) return;
102         resolveIntent(intent, true /* allowSelfOpen (ignored) */);
103     }
104 
105     /**
106      * Retrieve the best activity for the given intent. If a default activity is provided,
107      * choose the default one. Otherwise, return the Intent picker if there are more than one
108      * capable activities. If the intent is pdf type, return the platform pdf viewer if
109      * it is available so user don't need to choose it from Intent picker.
110      *
111      * Note this function is slow on Android versions less than Lollipop.
112      *
113      * @param intent Intent to open.
114      * @param allowSelfOpen Whether chrome itself is allowed to open the intent.
115      * @return true if the intent can be resolved, or false otherwise.
116      */
resolveIntent(Intent intent, boolean allowSelfOpen)117     public static boolean resolveIntent(Intent intent, boolean allowSelfOpen) {
118         Context context = ContextUtils.getApplicationContext();
119         ResolveInfo info = PackageManagerUtils.resolveActivity(intent, 0);
120         if (info == null) return false;
121 
122         final String packageName = context.getPackageName();
123         if (info.match != 0) {
124             // There is a default activity for this intent, use that.
125             return allowSelfOpen || !packageName.equals(info.activityInfo.packageName);
126         }
127         List<ResolveInfo> handlers = PackageManagerUtils.queryIntentActivities(
128                 intent, PackageManager.MATCH_DEFAULT_ONLY);
129         if (handlers == null || handlers.isEmpty()) return false;
130         boolean canSelfOpen = false;
131         boolean hasPdfViewer = false;
132         for (ResolveInfo resolveInfo : handlers) {
133             String pName = resolveInfo.activityInfo.packageName;
134             if (packageName.equals(pName)) {
135                 canSelfOpen = true;
136             } else if (PDF_VIEWER.equals(pName)) {
137                 if (isPdfIntent(intent)) {
138                     intent.setClassName(pName, resolveInfo.activityInfo.name);
139                     // TODO(crbug.com/1031465): Use IntentHandler.java's version of this constant
140                     // once it's componentized.
141                     Uri referrer = new Uri.Builder()
142                                            .scheme(ANDROID_APP_REFERRER_SCHEME)
143                                            .authority(packageName)
144                                            .build();
145                     intent.putExtra(Intent.EXTRA_REFERRER, referrer);
146                     hasPdfViewer = true;
147                     break;
148                 }
149             }
150         }
151         return !canSelfOpen || allowSelfOpen || hasPdfViewer;
152     }
153 
isPdfIntent(Intent intent)154     private static boolean isPdfIntent(Intent intent) {
155         if (intent == null || intent.getData() == null) return false;
156         String filename = intent.getData().getLastPathSegment();
157         return (filename != null && filename.endsWith(PDF_SUFFIX))
158                 || PDF_MIME.equals(intent.getType());
159     }
160 
161     @Override
queryIntentActivities(Intent intent)162     public List<ResolveInfo> queryIntentActivities(Intent intent) {
163         return PackageManagerUtils.queryIntentActivities(
164                 intent, PackageManager.GET_RESOLVED_FILTER);
165     }
166 
167     @Override
willChromeHandleIntent(Intent intent)168     public boolean willChromeHandleIntent(Intent intent) {
169         return false;
170     }
171 
172     @Override
shouldDisableExternalIntentRequestsForUrl(String url)173     public boolean shouldDisableExternalIntentRequestsForUrl(String url) {
174         return false;
175     }
176 
177     @Override
countSpecializedHandlers(List<ResolveInfo> infos)178     public int countSpecializedHandlers(List<ResolveInfo> infos) {
179         return getSpecializedHandlersWithFilter(infos, null).size();
180     }
181 
182     @Override
getSpecializedHandlers(List<ResolveInfo> infos)183     public ArrayList<String> getSpecializedHandlers(List<ResolveInfo> infos) {
184         return getSpecializedHandlersWithFilter(infos, null);
185     }
186 
187     @VisibleForTesting
getSpecializedHandlersWithFilter( List<ResolveInfo> infos, String filterPackageName)188     public static ArrayList<String> getSpecializedHandlersWithFilter(
189             List<ResolveInfo> infos, String filterPackageName) {
190         ArrayList<String> result = new ArrayList<>();
191         if (infos == null) {
192             return result;
193         }
194 
195         for (ResolveInfo info : infos) {
196             if (!matchResolveInfoExceptWildCardHost(info, filterPackageName)) {
197                 continue;
198             }
199 
200             if (info.activityInfo != null) {
201                 result.add(info.activityInfo.packageName);
202             } else {
203                 result.add("");
204             }
205         }
206         return result;
207     }
208 
matchResolveInfoExceptWildCardHost( ResolveInfo info, String filterPackageName)209     private static boolean matchResolveInfoExceptWildCardHost(
210             ResolveInfo info, String filterPackageName) {
211         IntentFilter intentFilter = info.filter;
212         if (intentFilter == null) {
213             // Error on the side of classifying ResolveInfo as generic.
214             return false;
215         }
216         if (intentFilter.countDataAuthorities() == 0 && intentFilter.countDataPaths() == 0) {
217             // Don't count generic handlers.
218             return false;
219         }
220         boolean isWildCardHost = false;
221         Iterator<IntentFilter.AuthorityEntry> it = intentFilter.authoritiesIterator();
222         while (it != null && it.hasNext()) {
223             IntentFilter.AuthorityEntry entry = it.next();
224             if ("*".equals(entry.getHost())) {
225                 isWildCardHost = true;
226                 break;
227             }
228         }
229         if (isWildCardHost) {
230             return false;
231         }
232         if (!TextUtils.isEmpty(filterPackageName)
233                 && (info.activityInfo == null
234                         || !info.activityInfo.packageName.equals(filterPackageName))) {
235             return false;
236         }
237         return true;
238     }
239 
240     /**
241      * Check whether the given package is a specialized handler for the given intent
242      *
243      * @param packageName Package name to check against. Can be null or empty.
244      * @param intent The intent to resolve for.
245      * @return Whether the given package is a specialized handler for the given intent. If there is
246      *         no package name given checks whether there is any specialized handler.
247      */
isPackageSpecializedHandler(String packageName, Intent intent)248     public static boolean isPackageSpecializedHandler(String packageName, Intent intent) {
249         List<ResolveInfo> handlers = PackageManagerUtils.queryIntentActivities(
250                 intent, PackageManager.GET_RESOLVED_FILTER);
251         return !getSpecializedHandlersWithFilter(handlers, packageName).isEmpty();
252     }
253 
254     @Override
startActivity(Intent intent, boolean proxy)255     public void startActivity(Intent intent, boolean proxy) {
256         assert !proxy
257             : "|proxy| should be true only for instant apps, which WebLayer doesn't handle";
258         try {
259             forcePdfViewerAsIntentHandlerIfNeeded(intent);
260             Context context = getAvailableContext();
261             if (!(context instanceof Activity)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
262             context.startActivity(intent);
263             recordExternalNavigationDispatched(intent);
264         } catch (RuntimeException e) {
265             IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
266         }
267     }
268 
269     @Override
startActivityIfNeeded(Intent intent, boolean proxy)270     public boolean startActivityIfNeeded(Intent intent, boolean proxy) {
271         assert !proxy
272             : "|proxy| should be true only for instant apps, which WebLayer doesn't handle";
273 
274         boolean activityWasLaunched;
275         // Only touches disk on Kitkat. See http://crbug.com/617725 for more context.
276         StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
277         try {
278             forcePdfViewerAsIntentHandlerIfNeeded(intent);
279             Context context = getAvailableContext();
280             if (context instanceof Activity) {
281                 activityWasLaunched = ((Activity) context).startActivityIfNeeded(intent, -1);
282             } else {
283                 activityWasLaunched = false;
284             }
285             if (activityWasLaunched) recordExternalNavigationDispatched(intent);
286             return activityWasLaunched;
287         } catch (SecurityException e) {
288             // https://crbug.com/808494: Handle the URL in WebLayer if dispatching to another
289             // application fails with a SecurityException. This happens due to malformed manifests
290             // in another app.
291             return false;
292         } catch (RuntimeException e) {
293             IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
294             return false;
295         } finally {
296             StrictMode.setThreadPolicy(oldPolicy);
297         }
298     }
299 
recordExternalNavigationDispatched(Intent intent)300     private void recordExternalNavigationDispatched(Intent intent) {
301         ArrayList<String> specializedHandlers =
302                 intent.getStringArrayListExtra(EXTRA_EXTERNAL_NAV_PACKAGES);
303         if (specializedHandlers != null && specializedHandlers.size() > 0) {
304             RecordUserAction.record("MobileExternalNavigationDispatched");
305         }
306     }
307 
308     @Override
startIncognitoIntent(final Intent intent, final String referrerUrl, final String fallbackUrl, final boolean needsToCloseTab, final boolean proxy)309     public boolean startIncognitoIntent(final Intent intent, final String referrerUrl,
310             final String fallbackUrl, final boolean needsToCloseTab, final boolean proxy) {
311         // TODO(crbug.com/1063399): Determine if this behavior should be refined.
312         startActivity(intent, proxy);
313         return true;
314     }
315 
316     @Override
shouldRequestFileAccess(String url)317     public boolean shouldRequestFileAccess(String url) {
318         // If the tab is null, then do not attempt to prompt for access.
319         if (!hasValidTab()) return false;
320 
321         // If the url points inside of Chromium's data directory, no permissions are necessary.
322         // This is required to prevent permission prompt when uses wants to access offline pages.
323         if (url.startsWith(UrlConstants.FILE_URL_PREFIX + PathUtils.getDataDirectory())) {
324             return false;
325         }
326 
327         return !mTab.getBrowser().getWindowAndroid().hasPermission(permission.READ_EXTERNAL_STORAGE)
328                 && mTab.getBrowser().getWindowAndroid().canRequestPermission(
329                         permission.READ_EXTERNAL_STORAGE);
330     }
331 
332     @Override
startFileIntent( final Intent intent, final String referrerUrl, final boolean needsToCloseTab)333     public void startFileIntent(
334             final Intent intent, final String referrerUrl, final boolean needsToCloseTab) {
335         PermissionCallback permissionCallback = new PermissionCallback() {
336             @Override
337             public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
338                 if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED
339                         && hasValidTab()) {
340                     String url = intent.getDataString();
341                     LoadUrlParams loadUrlParams =
342                             new LoadUrlParams(url, PageTransition.AUTO_TOPLEVEL);
343                     if (!TextUtils.isEmpty(referrerUrl)) {
344                         Referrer referrer = new Referrer(referrerUrl, ReferrerPolicy.ALWAYS);
345                         loadUrlParams.setReferrer(referrer);
346                     }
347                     mTab.loadUrl(loadUrlParams);
348                 } else {
349                     // TODO(tedchoc): Show an indication to the user that the navigation failed
350                     //                instead of silently dropping it on the floor.
351                     if (needsToCloseTab) {
352                         // If the access was not granted, then close the tab if necessary.
353                         closeTab();
354                     }
355                 }
356             }
357         };
358         if (!hasValidTab()) return;
359         mTab.getBrowser().getWindowAndroid().requestPermissions(
360                 new String[] {permission.READ_EXTERNAL_STORAGE}, permissionCallback);
361     }
362 
363     @Override
clobberCurrentTab(String url, String referrerUrl)364     public @OverrideUrlLoadingResult int clobberCurrentTab(String url, String referrerUrl) {
365         int transitionType = PageTransition.LINK;
366         final LoadUrlParams loadUrlParams = new LoadUrlParams(url, transitionType);
367         if (!TextUtils.isEmpty(referrerUrl)) {
368             Referrer referrer = new Referrer(referrerUrl, ReferrerPolicy.ALWAYS);
369             loadUrlParams.setReferrer(referrer);
370         }
371         if (hasValidTab()) {
372             // Loading URL will start a new navigation which cancels the current one
373             // that this clobbering is being done for. It leads to UAF. To avoid that,
374             // we're loading URL asynchronously. See https://crbug.com/732260.
375             PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
376                 @Override
377                 public void run() {
378                     if (hasValidTab()) mTab.loadUrl(loadUrlParams);
379                 }
380             });
381             return OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB;
382         } else {
383             assert false : "clobberCurrentTab was called with an empty tab.";
384             Uri uri = Uri.parse(url);
385             Intent intent = new Intent(Intent.ACTION_VIEW, uri);
386             String packageName = ContextUtils.getApplicationContext().getPackageName();
387             intent.putExtra(Browser.EXTRA_APPLICATION_ID, packageName);
388             intent.addCategory(Intent.CATEGORY_BROWSABLE);
389             intent.setPackage(packageName);
390             startActivity(intent, false);
391             return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
392         }
393     }
394 
395     @Override
isChromeAppInForeground()396     public boolean isChromeAppInForeground() {
397         return mTab.getBrowser().isResumed();
398     }
399 
400     @Override
maybeSetWindowId(Intent intent)401     public void maybeSetWindowId(Intent intent) {}
402 
403     @Override
getDefaultSmsPackageName()404     public String getDefaultSmsPackageName() {
405         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null;
406         return Telephony.Sms.getDefaultSmsPackage(mApplicationContext);
407     }
408 
closeTab()409     private void closeTab() {
410         // Closing of tabs as part of intent launching is not yet implemented in WebLayer, and
411         // parameters are specified such that this flow should never be invoked.
412         // TODO(crbug.com/1031465): Adapt //chrome's logic for closing of tabs.
413         assert false;
414     }
415 
416     @Override
isPdfDownload(String url)417     public boolean isPdfDownload(String url) {
418         String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
419         if (TextUtils.isEmpty(fileExtension)) return false;
420 
421         return PDF_EXTENSION.equals(fileExtension);
422     }
423 
424     @Override
maybeRecordAppHandlersInIntent(Intent intent, List<ResolveInfo> infos)425     public void maybeRecordAppHandlersInIntent(Intent intent, List<ResolveInfo> infos) {
426         intent.putExtra(EXTRA_EXTERNAL_NAV_PACKAGES, getSpecializedHandlersWithFilter(infos, null));
427     }
428 
429     @Override
maybeAdjustInstantAppExtras(Intent intent, boolean isIntentToInstantApp)430     public void maybeAdjustInstantAppExtras(Intent intent, boolean isIntentToInstantApp) {}
431 
432     @Override
433     // This is relevant only if the intent ends up being handled by this app, which does not happen
434     // for WebLayer.
maybeSetUserGesture(Intent intent)435     public void maybeSetUserGesture(Intent intent) {}
436 
437     @Override
438     // This is relevant only if the intent ends up being handled by this app, which does not happen
439     // for WebLayer.
maybeSetPendingReferrer(Intent intent, String referrerUrl)440     public void maybeSetPendingReferrer(Intent intent, String referrerUrl) {}
441 
442     @Override
443     // This is relevant only if the intent ends up being handled by this app, which does not happen
444     // for WebLayer.
maybeSetPendingIncognitoUrl(Intent intent)445     public void maybeSetPendingIncognitoUrl(Intent intent) {}
446 
447     @Override
isSerpReferrer()448     public boolean isSerpReferrer() {
449         // TODO (thildebr): Investigate whether or not we can use getLastCommittedUrl() instead of
450         // the NavigationController.
451         if (!hasValidTab() || mTab.getWebContents() == null) return false;
452 
453         NavigationController nController = mTab.getWebContents().getNavigationController();
454         int index = nController.getLastCommittedEntryIndex();
455         if (index == -1) return false;
456 
457         NavigationEntry entry = nController.getEntryAtIndex(index);
458         if (entry == null) return false;
459 
460         return UrlUtilitiesJni.get().isGoogleSearchUrl(entry.getUrl());
461     }
462 
463     @Override
maybeLaunchInstantApp( String url, String referrerUrl, boolean isIncomingRedirect)464     public boolean maybeLaunchInstantApp(
465             String url, String referrerUrl, boolean isIncomingRedirect) {
466         return false;
467     }
468 
469     @Override
getPreviousUrl()470     public String getPreviousUrl() {
471         if (mTab == null || mTab.getWebContents() == null) return null;
472         return mTab.getWebContents().getLastCommittedUrl();
473     }
474 
475     /**
476      * @return Whether or not we have a valid {@link Tab} available.
477      */
hasValidTab()478     private boolean hasValidTab() {
479         assert mTab != null;
480         return !mTabDestroyed;
481     }
482 
483     @Override
isIntentForTrustedCallingApp(Intent intent)484     public boolean isIntentForTrustedCallingApp(Intent intent) {
485         return false;
486     }
487 
488     @Override
isIntentToInstantApp(Intent intent)489     public boolean isIntentToInstantApp(Intent intent) {
490         return false;
491     }
492 
493     @Override
isValidWebApk(String packageName)494     public boolean isValidWebApk(String packageName) {
495         // TODO(crbug.com/1063874): Determine whether to refine this.
496         return false;
497     }
498 
499     @Override
handleWithAutofillAssistant( ExternalNavigationParams params, Intent targetIntent, String browserFallbackUrl)500     public boolean handleWithAutofillAssistant(
501             ExternalNavigationParams params, Intent targetIntent, String browserFallbackUrl) {
502         return false;
503     }
504 }
505