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