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