1 // Copyright 2016 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.chrome.browser.instantapps; 6 7 import android.content.Context; 8 import android.content.Intent; 9 import android.net.Uri; 10 import android.os.Build; 11 import android.os.SystemClock; 12 import android.provider.Browser; 13 14 import org.chromium.base.ContextUtils; 15 import org.chromium.base.IntentUtils; 16 import org.chromium.base.Log; 17 import org.chromium.base.metrics.RecordHistogram; 18 import org.chromium.chrome.browser.AppHooks; 19 import org.chromium.chrome.browser.IntentHandler; 20 import org.chromium.chrome.browser.ShortcutHelper; 21 import org.chromium.chrome.browser.externalnav.ExternalNavigationDelegateImpl; 22 import org.chromium.chrome.browser.preferences.ChromePreferenceKeys; 23 import org.chromium.chrome.browser.preferences.SharedPreferencesManager; 24 import org.chromium.chrome.browser.tab.Tab; 25 import org.chromium.content_public.browser.WebContents; 26 27 /** A launcher for Instant Apps. */ 28 public class InstantAppsHandler { 29 private static final String TAG = "InstantAppsHandler"; 30 31 private static final Object INSTANCE_LOCK = new Object(); 32 private static InstantAppsHandler sInstance; 33 34 private static final String CUSTOM_APPS_INSTANT_APP_EXTRA = 35 "android.support.customtabs.extra.EXTRA_ENABLE_INSTANT_APPS"; 36 37 private static final String INSTANT_APP_START_TIME_EXTRA = 38 "org.chromium.chrome.INSTANT_APP_START_TIME"; 39 40 // TODO(mariakhomenko): Use system once we roll to O SDK. 41 private static final int FLAG_DO_NOT_LAUNCH = 0x00000200; 42 43 // TODO(mariakhomenko): Depend directly on the constants once we roll to v8 libraries. 44 private static final String DO_NOT_LAUNCH_EXTRA = 45 "com.google.android.gms.instantapps.DO_NOT_LAUNCH_INSTANT_APP"; 46 47 protected static final String IS_REFERRER_TRUSTED_EXTRA = 48 "com.google.android.gms.instantapps.IS_REFERRER_TRUSTED"; 49 50 protected static final String IS_USER_CONFIRMED_LAUNCH_EXTRA = 51 "com.google.android.gms.instantapps.IS_USER_CONFIRMED_LAUNCH"; 52 53 protected static final String TRUSTED_REFERRER_PKG_EXTRA = 54 "com.google.android.gms.instantapps.TRUSTED_REFERRER_PKG"; 55 56 public static final String IS_GOOGLE_SEARCH_REFERRER = 57 "com.google.android.gms.instantapps.IS_GOOGLE_SEARCH_REFERRER"; 58 59 private static final String BROWSER_LAUNCH_REASON = 60 "com.google.android.gms.instantapps.BROWSER_LAUNCH_REASON"; 61 62 private static final String SUPERVISOR_PKG = "com.google.android.instantapps.supervisor"; 63 64 private static final String[] SUPERVISOR_START_ACTIONS = { 65 "com.google.android.instantapps.START", "com.google.android.instantapps.nmr1.INSTALL", 66 "com.google.android.instantapps.nmr1.VIEW"}; 67 68 /** Finch experiment name. */ 69 private static final String INSTANT_APPS_EXPERIMENT_NAME = "InstantApps"; 70 71 /** Finch experiment group which is enabled for instant apps. */ 72 private static final String INSTANT_APPS_ENABLED_ARM = "InstantAppsEnabled"; 73 74 /** Finch experiment group which is disabled for instant apps. */ 75 private static final String INSTANT_APPS_DISABLED_ARM = "InstantAppsDisabled"; 76 77 // Only two possible call sources for fallback intents, set boundary at n+1. 78 private static final int SOURCE_BOUNDARY = 3; 79 80 /** @return The singleton instance of {@link InstantAppsHandler}. */ getInstance()81 public static InstantAppsHandler getInstance() { 82 synchronized (INSTANCE_LOCK) { 83 if (sInstance == null) { 84 sInstance = AppHooks.get().createInstantAppsHandler(); 85 } 86 } 87 return sInstance; 88 } 89 90 /** 91 * Checks whether {@param intent} is for an Instant App. Considers both package and actions that 92 * would resolve to Supervisor. 93 * @return Whether the given intent is going to open an Instant App. 94 */ isIntentToInstantApp(Intent intent)95 public static boolean isIntentToInstantApp(Intent intent) { 96 if (SUPERVISOR_PKG.equals(intent.getPackage())) return true; 97 98 String intentAction = intent.getAction(); 99 for (String action : SUPERVISOR_START_ACTIONS) { 100 if (action.equals(intentAction)) { 101 return true; 102 } 103 } 104 return false; 105 } 106 107 /** 108 * Record how long the handleIntent() method took. 109 * @param startTime The timestamp for handleIntent start time. 110 */ recordHandleIntentDuration(long startTime)111 private void recordHandleIntentDuration(long startTime) { 112 RecordHistogram.recordTimesHistogram("Android.InstantApps.HandleIntentDuration", 113 SystemClock.elapsedRealtime() - startTime); 114 } 115 116 /** 117 * Record the amount of time spent in the Instant Apps API call. 118 * @param startTime The time at which we started doing computations. 119 * @param hasApp Whether the API has found an Instant App during the call. 120 */ recordInstantAppsApiCallTime(long startTime, boolean hasApp)121 protected void recordInstantAppsApiCallTime(long startTime, boolean hasApp) { 122 if (hasApp) { 123 RecordHistogram.recordTimesHistogram("Android.InstantApps.ApiCallDurationWithApp", 124 SystemClock.elapsedRealtime() - startTime); 125 } else { 126 RecordHistogram.recordTimesHistogram("Android.InstantApps.ApiCallDurationWithoutApp", 127 SystemClock.elapsedRealtime() - startTime); 128 } 129 } 130 131 /** 132 * In the case where Chrome is called through the fallback mechanism from Instant Apps, 133 * record the amount of time the whole trip took and which UI took the user back to Chrome, 134 * if any. 135 * @param intent The current intent. 136 */ maybeRecordFallbackStats(Intent intent)137 private void maybeRecordFallbackStats(Intent intent) { 138 Long startTime = IntentUtils.safeGetLongExtra(intent, INSTANT_APP_START_TIME_EXTRA, 0); 139 if (startTime > 0) { 140 RecordHistogram.recordTimesHistogram("Android.InstantApps.FallbackDuration", 141 SystemClock.elapsedRealtime() - startTime); 142 intent.removeExtra(INSTANT_APP_START_TIME_EXTRA); 143 } 144 int callSource = IntentUtils.safeGetIntExtra(intent, BROWSER_LAUNCH_REASON, 0); 145 if (callSource > 0 && callSource < SOURCE_BOUNDARY) { 146 RecordHistogram.recordEnumeratedHistogram( 147 "Android.InstantApps.CallSource", callSource, SOURCE_BOUNDARY); 148 intent.removeExtra(BROWSER_LAUNCH_REASON); 149 } else if (callSource >= SOURCE_BOUNDARY) { 150 Log.e(TAG, "Unexpected call source constant for Instant Apps: " + callSource); 151 } 152 } 153 154 /** 155 * Handle incoming intent. 156 * @param context Context. 157 * @param intent The incoming intent being handled. 158 * @param isCustomTabsIntent Whether we are in custom tabs. 159 * @param isRedirect Whether this is the redirect resolve case where incoming intent was 160 * resolved to another URL. 161 * @return Whether Instant Apps is handling the URL request. 162 */ handleIncomingIntent(Context context, Intent intent, boolean isCustomTabsIntent, boolean isRedirect)163 public boolean handleIncomingIntent(Context context, Intent intent, 164 boolean isCustomTabsIntent, boolean isRedirect) { 165 long startTimeStamp = SystemClock.elapsedRealtime(); 166 boolean result = handleIncomingIntentInternal(context, intent, isCustomTabsIntent, 167 startTimeStamp, isRedirect); 168 recordHandleIntentDuration(startTimeStamp); 169 return result; 170 } 171 handleIncomingIntentInternal( Context context, Intent intent, boolean isCustomTabsIntent, long startTime, boolean isRedirect)172 private boolean handleIncomingIntentInternal( 173 Context context, Intent intent, boolean isCustomTabsIntent, long startTime, 174 boolean isRedirect) { 175 if (!isRedirect && !isCustomTabsIntent && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 176 Log.i(TAG, "Package manager handles intents on O+, not handling in Chrome"); 177 return false; 178 } 179 180 if (isCustomTabsIntent && !IntentUtils.safeGetBooleanExtra( 181 intent, CUSTOM_APPS_INSTANT_APP_EXTRA, false)) { 182 Log.i(TAG, "Not handling with Instant Apps (missing CUSTOM_APPS_INSTANT_APP_EXTRA)"); 183 return false; 184 } 185 186 if (IntentUtils.safeGetBooleanExtra(intent, DO_NOT_LAUNCH_EXTRA, false) 187 || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O 188 && (intent.getFlags() & FLAG_DO_NOT_LAUNCH) != 0)) { 189 maybeRecordFallbackStats(intent); 190 Log.i(TAG, "Not handling with Instant Apps (DO_NOT_LAUNCH_EXTRA)"); 191 return false; 192 } 193 194 if (IntentUtils.safeGetBooleanExtra( 195 intent, IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, false) 196 || IntentUtils.safeHasExtra(intent, ShortcutHelper.EXTRA_SOURCE) 197 || isIntentFromChrome(context, intent) 198 || (IntentHandler.getUrlFromIntent(intent) == null)) { 199 Log.i(TAG, "Not handling with Instant Apps (other)"); 200 return false; 201 } 202 203 // Used to search for the intent handlers. Needs null component to return correct results. 204 Intent intentCopy = new Intent(intent); 205 intentCopy.setComponent(null); 206 Intent selector = intentCopy.getSelector(); 207 if (selector != null) selector.setComponent(null); 208 209 if (!(isCustomTabsIntent || isChromeDefaultHandler(context)) 210 || ExternalNavigationDelegateImpl.isPackageSpecializedHandler(null, intentCopy)) { 211 // Chrome is not the default browser or a specialized handler exists. 212 Log.i(TAG, "Not handling with Instant Apps because Chrome is not default or " 213 + "there's a specialized handler"); 214 return false; 215 } 216 217 Intent callbackIntent = new Intent(intent); 218 callbackIntent.putExtra(DO_NOT_LAUNCH_EXTRA, true); 219 callbackIntent.putExtra(INSTANT_APP_START_TIME_EXTRA, startTime); 220 221 return tryLaunchingInstantApp(context, intent, isCustomTabsIntent, callbackIntent); 222 } 223 224 /** 225 * Attempts to launch an Instant App, if possible. 226 * @param context The activity context. 227 * @param intent The incoming intent. 228 * @param isCustomTabsIntent Whether the intent is for a CustomTab. 229 * @param fallbackIntent The intent that will be launched by Instant Apps in case of failure to 230 * load. 231 * @return Whether an Instant App was launched. 232 */ tryLaunchingInstantApp( Context context, Intent intent, boolean isCustomTabsIntent, Intent fallbackIntent)233 protected boolean tryLaunchingInstantApp( 234 Context context, Intent intent, boolean isCustomTabsIntent, Intent fallbackIntent) { 235 return false; 236 } 237 238 /** 239 * Evaluate a navigation for whether it should launch an Instant App or show the Instant 240 * App banner. 241 * @return Whether an Instant App intent was started. 242 */ handleNavigation(Context context, String url, Uri referrer, Tab tab)243 public boolean handleNavigation(Context context, String url, Uri referrer, Tab tab) { 244 boolean urlIsInstantAppDefault = 245 InstantAppsSettings.isInstantAppDefault(tab.getWebContents(), url); 246 if (shouldLaunchInstantApp(tab.getWebContents(), url, referrer, urlIsInstantAppDefault)) { 247 return launchInstantAppForNavigation(context, url, referrer); 248 } 249 maybeShowInstantAppBanner(context, url, referrer, tab, urlIsInstantAppDefault); 250 return false; 251 } 252 253 /** 254 * Returns whether or not we should launch an instant app immediately for the given URL. 255 * 256 * @param webContents A {@link WebContents}. 257 * @param url The URL we might launch an instant app for. 258 * @param referrer The referring URL. 259 * @return Whether we should launch the instant app. 260 */ shouldLaunchInstantApp( WebContents webContents, String url, Uri referrer, boolean urlIsInstantAppDefault)261 private boolean shouldLaunchInstantApp( 262 WebContents webContents, String url, Uri referrer, boolean urlIsInstantAppDefault) { 263 // Launch the instant app automatically on these conditions: 264 // a) The host of the current URL and referrer are different, and the user has chosen to 265 // launch this instant app in the past. 266 // b) The host of the current URL and referrer are the same, but the referrer URL isn't 267 // handled by an instant app and the current one is. 268 if (!urlIsInstantAppDefault) return false; 269 270 String urlHost = Uri.parse(url).getHost(); 271 boolean sameHosts = 272 referrer != null && urlHost != null && urlHost.equals(referrer.getHost()); 273 return (sameHosts && getInstantAppIntentForUrl(referrer.toString()) == null) || !sameHosts; 274 } 275 276 /** 277 * Shows an Instant App banner if necessary for the page we're loading. 278 * 279 * @param context An Android {@link Context}. 280 * @param url The URL we're navigating to. 281 * @param referrer The referrer {@link Uri}. 282 * @param tab A Chrome {@link Tab}. 283 * @param isInstantAppDefault Whether this instant app is being opened by default. 284 */ maybeShowInstantAppBanner( Context context, String url, Uri referrer, Tab tab, boolean isInstantAppDefault)285 protected void maybeShowInstantAppBanner( 286 Context context, String url, Uri referrer, Tab tab, boolean isInstantAppDefault) {} 287 288 /** 289 * Launches an Instant App immediately, if possible. 290 */ launchInstantAppForNavigation(Context context, String url, Uri referrer)291 protected boolean launchInstantAppForNavigation(Context context, String url, Uri referrer) { 292 return false; 293 } 294 295 /** 296 * @return Whether the intent was fired from Chrome. This happens when the user gets a 297 * disambiguation dialog and chooses to stay in Chrome. 298 */ isIntentFromChrome(Context context, Intent intent)299 private boolean isIntentFromChrome(Context context, Intent intent) { 300 return context.getPackageName().equals(IntentUtils.safeGetStringExtra( 301 intent, Browser.EXTRA_APPLICATION_ID)) 302 // We shouldn't leak internal intents with authentication tokens 303 || IntentHandler.wasIntentSenderChrome(intent); 304 } 305 306 /** @return Whether Chrome is the default browser on the device. */ isChromeDefaultHandler(Context context)307 private boolean isChromeDefaultHandler(Context context) { 308 return SharedPreferencesManager.getInstance().readBoolean( 309 ChromePreferenceKeys.CHROME_DEFAULT_BROWSER, false); 310 } 311 312 /** 313 * Launches the Instant App from the infobar banner. 314 */ launchFromBanner(InstantAppsBannerData data)315 public void launchFromBanner(InstantAppsBannerData data) { 316 if (data.getIntent() == null) return; 317 318 Intent iaIntent = data.getIntent(); 319 if (data.getReferrer() != null) { 320 iaIntent.putExtra(Intent.EXTRA_REFERRER, data.getReferrer()); 321 iaIntent.putExtra(IS_REFERRER_TRUSTED_EXTRA, true); 322 } 323 324 Context appContext = ContextUtils.getApplicationContext(); 325 iaIntent.putExtra(TRUSTED_REFERRER_PKG_EXTRA, appContext.getPackageName()); 326 iaIntent.putExtra(IS_USER_CONFIRMED_LAUNCH_EXTRA, true); 327 328 try { 329 appContext.startActivity(iaIntent); 330 InstantAppsSettings.setInstantAppDefault(data.getWebContents(), data.getUrl()); 331 } catch (Exception e) { 332 Log.e(TAG, "Could not launch instant app intent", e); 333 } 334 } 335 336 /** 337 * Gets the instant app intent for the given URL if one exists. 338 * 339 * @param url The URL whose instant app this is associated with. 340 * @return An instant app intent for the URL if one exists. 341 */ getInstantAppIntentForUrl(String url)342 public Intent getInstantAppIntentForUrl(String url) { 343 return null; 344 } 345 346 /** 347 * Returns whether or not the instant app is available. 348 * 349 * @param url The URL where the instant app is located. 350 * @param checkHoldback Check if the app would be available if the user weren't in the holdback 351 * group. 352 * @param includeUserPrefersBrowser Function should return true if there's an instant app intent 353 * even if the user has opted out of instant apps. 354 * @return Whether or not the instant app specified by the entry in the page's manifest is 355 * either available, or would be available if the user wasn't in the holdback group. 356 */ isInstantAppAvailable( String url, boolean checkHoldback, boolean includeUserPrefersBrowser)357 public boolean isInstantAppAvailable( 358 String url, boolean checkHoldback, boolean includeUserPrefersBrowser) { 359 return false; 360 } 361 } 362