Support for measuring page load times.

An app can attached a pending intent to an intent sent to the browser
which will be send when the load completes. The browser will fill in
the timestamp of when the page load completed, and details of preloading
success or otherwise if this was used.

Bug: 5259031
Change-Id: I2d025caabd6055ac25f06e69897a03d5c57c4f41
diff --git a/src/com/android/browser/Controller.java b/src/com/android/browser/Controller.java
index 67c42dd..d05a845 100644
--- a/src/com/android/browser/Controller.java
+++ b/src/com/android/browser/Controller.java
@@ -18,6 +18,7 @@
 
 import android.app.Activity;
 import android.app.DownloadManager;
+import android.app.PendingIntent;
 import android.app.SearchManager;
 import android.content.ClipboardManager;
 import android.content.ContentProvider;
@@ -93,6 +94,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 /**
  * Controller for browser
@@ -761,6 +763,7 @@
     public void stopLoading() {
         mLoadStopped = true;
         Tab tab = mTabControl.getCurrentTab();
+        tab.clearPageLoadCompleteListener();
         WebView w = getCurrentTopWebView();
         w.stopLoading();
         mUi.onPageStopped(tab);
@@ -2154,7 +2157,8 @@
         final PreloadedTabControl tabControl = urlData.getPreloadedTab();
         final String sbQuery = urlData.getSearchBoxQueryToSubmit();
         if (sbQuery != null) {
-            if (!tabControl.searchBoxSubmit(sbQuery, urlData.mUrl, urlData.mHeaders)) {
+            if (!tabControl.searchBoxSubmit(sbQuery, urlData.mUrl, urlData.mHeaders,
+                    urlData.getOnLoadCompletePendingIntent())) {
                 // Could not submit query. Fallback to regular tab creation
                 tabControl.destroy();
                 return null;
@@ -2172,6 +2176,18 @@
         mTabControl.addPreloadedTab(t);
         addTab(t);
         setActiveTab(t);
+        if (sbQuery == null) {
+            // if the searchbox query is set, the load complete notification is handled within
+            // the preloaded tab controller.
+            if (t.inPageLoad()) {
+                requestLoadCompleteNotification(urlData.mOnLoadCompletePendingIntent, t,
+                        urlData.mUrl, true, true);
+            } else {
+                // the page is already fully loaded
+                IntentHandler.sendPageLoadCompletePendingIntent(mActivity,
+                        urlData.mOnLoadCompletePendingIntent, true, true);
+            }
+        }
         return t;
     }
 
@@ -2365,10 +2381,26 @@
                 // this isn't called for preloaded tabs
             } else {
                 loadUrl(t, data.mUrl, data.mHeaders);
+                requestLoadCompleteNotification(data.mOnLoadCompletePendingIntent, t, data.mUrl,
+                        null, null);
             }
         }
     }
 
+    private void requestLoadCompleteNotification(final PendingIntent loadCompletePendingIntent,
+            Tab t, String forUrl, final Boolean preloaded, final Boolean preloadSuccess) {
+        if (loadCompletePendingIntent != null) {
+            Pattern urlMatch = Pattern.compile(Pattern.quote(forUrl));
+            t.setOnPageLoadCompleteListener(urlMatch, new Tab.OnPageLoadCompleteListener() {
+                @Override
+                public void onPageLoadComplete() {
+                    IntentHandler.sendPageLoadCompletePendingIntent(mActivity,
+                            loadCompletePendingIntent, preloaded, preloadSuccess);
+                }
+            });
+        }
+    }
+
     @Override
     public void onUserCanceledSsl(Tab tab) {
         // TODO: Figure out the "right" behavior
diff --git a/src/com/android/browser/IntentHandler.java b/src/com/android/browser/IntentHandler.java
index cc6b57c..178ba62 100644
--- a/src/com/android/browser/IntentHandler.java
+++ b/src/com/android/browser/IntentHandler.java
@@ -18,6 +18,8 @@
 package com.android.browser;
 
 import android.app.Activity;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
 import android.app.SearchManager;
 import android.content.ContentResolver;
 import android.content.Context;
@@ -30,6 +32,7 @@
 import android.provider.MediaStore;
 import android.speech.RecognizerResultsIntent;
 import android.text.TextUtils;
+import android.util.Log;
 import android.util.Patterns;
 
 import com.android.browser.search.SearchEngine;
@@ -50,6 +53,22 @@
     // "source" parameter for Google search from unknown source
     final static String GOOGLE_SEARCH_SOURCE_UNKNOWN = "unknown";
 
+    // Pending intent extra attached to browser intents that is broadcast when the page load
+    // completes.
+    // TODO move to android.provider.Browser & make public?
+    private static final String EXTRA_LOAD_COMPLETE_PENDINGINTENT = "load_complete_intent";
+    // extra attached to intent received via EXTRA_LOAD_COMPLETE_PENDINGINTENT indicating the
+    // time at which the load completed.
+    public static final String EXTRA_LOAD_COMPLETION_TIME = "completets";
+    // extra attached to intent received via EXTRA_LOAD_COMPLETE_PENDINGINTENT indicating if
+    // preloading was attempted.
+    public static final String EXTRA_PREFETCH_ATTEMPTED = "prefattempt";
+    // extra attached to intent received via EXTRA_LOAD_COMPLETE_PENDINGINTENT indicating if
+    // preloading succeeded.
+    public static final String EXTRA_PREFETCH_SUCCESS = "prefsuccess";
+
+
+
     /* package */ static final UrlData EMPTY_URL_DATA = new UrlData(null);
 
     private Activity mActivity;
@@ -224,11 +243,39 @@
         }
     }
 
+    /**
+     * Send a pending intent received in a page view intent. This should be called when the page
+     * has finished loading.
+     *
+     * @param prefetchAttempted Indicates if prefetching was attempted, {@code null} if prefetching
+     *      was not requested or is disabled.
+     * @param prefetchSucceeded Indicates if prefetching succeeded, {@code null} if prefetching
+     *      was not requested or is disabled.
+     */
+    public static void sendPageLoadCompletePendingIntent(Context context, PendingIntent pi,
+            Boolean prefetchAttempted, Boolean prefetchSucceeded) {
+        if (pi == null) return;
+        Intent fillIn = new Intent();
+        fillIn.putExtra(EXTRA_LOAD_COMPLETION_TIME, System.currentTimeMillis());
+        if (prefetchAttempted != null) {
+            fillIn.putExtra(EXTRA_PREFETCH_ATTEMPTED, prefetchAttempted.booleanValue());
+        }
+        if (prefetchSucceeded != null) {
+            fillIn.putExtra(EXTRA_PREFETCH_SUCCESS, prefetchSucceeded.booleanValue());
+        }
+        try {
+            pi.send(context, Activity.RESULT_OK, fillIn);
+        } catch (CanceledException e) {
+            // ignore
+        }
+    }
+
     protected static UrlData getUrlDataFromIntent(Intent intent) {
         String url = "";
         Map<String, String> headers = null;
         PreloadedTabControl preloaded = null;
         String preloadedSearchBoxQuery = null;
+        PendingIntent loadCompletePendingIntent = null;
         if (intent != null
                 && (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
             final String action = intent.getAction();
@@ -253,6 +300,10 @@
                             PreloadRequestReceiver.EXTRA_SEARCHBOX_SETQUERY);
                     preloaded = Preloader.getInstance().getPreloadedTab(id);
                 }
+                if (intent.hasExtra(EXTRA_LOAD_COMPLETE_PENDINGINTENT)) {
+                    loadCompletePendingIntent =
+                        intent.getParcelableExtra(EXTRA_LOAD_COMPLETE_PENDINGINTENT);
+                }
             } else if (Intent.ACTION_SEARCH.equals(action)
                     || MediaStore.INTENT_ACTION_MEDIA_SEARCH.equals(action)
                     || Intent.ACTION_WEB_SEARCH.equals(action)) {
@@ -277,7 +328,8 @@
                 }
             }
         }
-        return new UrlData(url, headers, intent, preloaded, preloadedSearchBoxQuery);
+        return new UrlData(url, headers, intent, preloaded, preloadedSearchBoxQuery,
+                loadCompletePendingIntent);
     }
 
     /**
@@ -362,6 +414,7 @@
         final Intent mVoiceIntent;
         final PreloadedTabControl mPreloadedTab;
         final String mSearchBoxQueryToSubmit;
+        final PendingIntent mOnLoadCompletePendingIntent;
 
         UrlData(String url) {
             this.mUrl = url;
@@ -369,14 +422,16 @@
             this.mVoiceIntent = null;
             this.mPreloadedTab = null;
             this.mSearchBoxQueryToSubmit = null;
+            this.mOnLoadCompletePendingIntent = null;
         }
 
         UrlData(String url, Map<String, String> headers, Intent intent) {
-            this(url, headers, intent, null, null);
+            this(url, headers, intent, null, null, null);
         }
 
         UrlData(String url, Map<String, String> headers, Intent intent,
-                PreloadedTabControl preloaded, String searchBoxQueryToSubmit) {
+                PreloadedTabControl preloaded, String searchBoxQueryToSubmit,
+                PendingIntent onLoadCompletePendingIntent) {
             this.mUrl = url;
             this.mHeaders = headers;
             if (RecognizerResultsIntent.ACTION_VOICE_SEARCH_RESULTS
@@ -387,6 +442,7 @@
             }
             this.mPreloadedTab = preloaded;
             this.mSearchBoxQueryToSubmit = searchBoxQueryToSubmit;
+            this.mOnLoadCompletePendingIntent = onLoadCompletePendingIntent;
         }
 
         boolean isEmpty() {
@@ -404,6 +460,10 @@
         String getSearchBoxQueryToSubmit() {
             return mSearchBoxQueryToSubmit;
         }
+
+        PendingIntent getOnLoadCompletePendingIntent() {
+            return mOnLoadCompletePendingIntent;
+        }
     }
 
 }
diff --git a/src/com/android/browser/PreloadedTabControl.java b/src/com/android/browser/PreloadedTabControl.java
index 4ffe6b4..b0eff63 100644
--- a/src/com/android/browser/PreloadedTabControl.java
+++ b/src/com/android/browser/PreloadedTabControl.java
@@ -15,6 +15,7 @@
  */
 package com.android.browser;
 
+import android.app.PendingIntent;
 import android.net.Uri;
 import android.text.TextUtils;
 import android.util.Log;
@@ -39,7 +40,7 @@
         mTab = t;
     }
 
-    private void maybeSetQuery(final String query, SearchBox sb) {
+    private boolean maybeSetQuery(final String query, SearchBox sb) {
         if (!TextUtils.equals(mLastQuery, query)) {
             if (sb != null) {
                 if (LOGD_ENABLED) Log.d(LOGTAG, "Changing searchbox query to " + query);
@@ -55,25 +56,27 @@
                         }
                     }
                 });
+                return true;
             } else {
                 if (LOGD_ENABLED) Log.d(LOGTAG, "Cannot set query: no searchbox interface");
             }
         }
+        return false;
     }
 
     public void setQuery(String query) {
         maybeSetQuery(query, mTab.getWebView().getSearchBox());
     }
 
-    public boolean searchBoxSubmit(final String query,
-            final String fallbackUrl, final Map<String, String> fallbackHeaders) {
+    public boolean searchBoxSubmit(final String query, final String fallbackUrl,
+            final Map<String, String> fallbackHeaders, final PendingIntent onLoadCompleteIntent) {
         final SearchBox sb = mTab.getWebView().getSearchBox();
         if (sb == null) {
             // no searchbox, cannot submit. Fallback to regular tab creation
             if (LOGD_ENABLED) Log.d(LOGTAG, "No searchbox, cannot submit query");
             return false;
         }
-        maybeSetQuery(query, sb);
+        final boolean newQuery = maybeSetQuery(query, sb);
         if (LOGD_ENABLED) Log.d(LOGTAG, "Submitting query " + query);
         final String currentUrl = mTab.getUrl();
         sb.onsubmit(new SearchBox.SearchBoxListener() {
@@ -84,9 +87,14 @@
                 if (!called) {
                     if (LOGD_ENABLED) Log.d(LOGTAG, "Query not submitted; falling back");
                     loadUrl(fallbackUrl, fallbackHeaders);
+
                     // make sure that the failed, preloaded URL is cleared from the back stack
                     mTab.clearBackStackWhenItemAdded(Pattern.compile(
                             "^" + Pattern.quote(fallbackUrl) + "$"));
+                    // When setting the search box query, preloadAttempted=true implies that the
+                    // the query was prefetched using the searchbox API. This is the case if we
+                    // the query is not new.
+                    registerLoadCompleteListener(!newQuery, false, onLoadCompleteIntent);
                 } else {
                     // ignore the next fragment change, to avoid leaving a blank page in the browser
                     // after the query has been submitted.
@@ -100,11 +108,27 @@
                                     Pattern.quote(currentWithoutFragment) +
                                     "(\\#.*)?" +
                                     "$"));
+                    registerLoadCompleteListener(!newQuery, true, onLoadCompleteIntent);
                 }
             }});
         return true;
     }
 
+    private void registerLoadCompleteListener(
+            final boolean queryPreloaded,
+            final boolean preloadSucceeded,
+            final PendingIntent pendingIntent) {
+        if (pendingIntent == null) {
+            return;
+        }
+        mTab.setOnPageLoadCompleteListener(null, new Tab.OnPageLoadCompleteListener(){
+            @Override
+            public void onPageLoadComplete() {
+                IntentHandler.sendPageLoadCompletePendingIntent(mTab.mContext, pendingIntent,
+                        queryPreloaded, preloadSucceeded);
+            }});
+    }
+
     public void searchBoxCancel() {
         SearchBox sb = mTab.getWebView().getSearchBox();
         if (sb != null) {
diff --git a/src/com/android/browser/Tab.java b/src/com/android/browser/Tab.java
index 8c9dc02..8dcc54b 100644
--- a/src/com/android/browser/Tab.java
+++ b/src/com/android/browser/Tab.java
@@ -66,8 +66,6 @@
 import android.webkit.WebView.PictureListener;
 import android.webkit.WebViewClient;
 import android.widget.CheckBox;
-import android.widget.LinearLayout;
-import android.widget.TextView;
 import android.widget.Toast;
 
 import com.android.browser.TabControl.OnThumbnailUpdatedListener;
@@ -174,10 +172,13 @@
     private Handler mHandler;
 
     /**
-     * See {@link #clearBackStackWhenItemAdded(String)}.
+     * See {@link #clearBackStackWhenItemAdded(Pattern)}.
      */
     private Pattern mClearHistoryUrlPattern;
 
+    private OnPageLoadCompleteListener mOnPageLoadCompleteListener;
+    private Pattern mOnPageLoadCompleteUrlMatch;
+
     private static synchronized Bitmap getDefaultFavicon(Context context) {
         if (sDefaultFavicon == null) {
             sDefaultFavicon = BitmapFactory.decodeResource(
@@ -532,6 +533,26 @@
         }
     }
 
+    public interface OnPageLoadCompleteListener {
+        void onPageLoadComplete();
+    }
+
+    /**
+     * Requests a notification when the next page load completes. This is a one shot notification,
+     * the listener will be discarded after the first callback, or if the page load is cancelled.
+     * @param listener
+     */
+    public void setOnPageLoadCompleteListener(Pattern urlMatch,
+            OnPageLoadCompleteListener listener) {
+        mOnPageLoadCompleteListener = listener;
+        mOnPageLoadCompleteUrlMatch = urlMatch;
+    }
+
+    public void clearPageLoadCompleteListener() {
+        mOnPageLoadCompleteListener = null;
+        mOnPageLoadCompleteUrlMatch = null;
+    }
+
     // -------------------------------------------------------------------------
     // WebViewClient implementation for the main WebView
     // -------------------------------------------------------------------------
@@ -595,6 +616,13 @@
 
         @Override
         public void onPageFinished(WebView view, String url) {
+            if (mOnPageLoadCompleteListener != null) {
+                if (mOnPageLoadCompleteUrlMatch == null
+                        || mOnPageLoadCompleteUrlMatch.matcher(url).matches())
+                mOnPageLoadCompleteListener.onPageLoadComplete();
+                mOnPageLoadCompleteListener = null;
+                mOnPageLoadCompleteUrlMatch = null;
+            }
             if (!mInPageLoad) {
                 // In page navigation links (www.something.com#footer) will
                 // trigger an onPageFinished which we don't care about.
@@ -2042,6 +2070,7 @@
             mCurrentState = new PageState(mContext, false, url, null);
             mWebViewController.onPageStarted(this, mMainView, null);
             mMainView.loadUrl(url, headers);
+            clearPageLoadCompleteListener();
         }
     }
 
@@ -2093,12 +2122,14 @@
 
     public void goBack() {
         if (mMainView != null) {
+            clearPageLoadCompleteListener();
             mMainView.goBack();
         }
     }
 
     public void goForward() {
         if (mMainView != null) {
+            clearPageLoadCompleteListener();
             mMainView.goForward();
         }
     }