Implement the psychic search engine.

(a) Add a new subclass of SearchEngine that receives
suggestions provided by psychic and displays them in the
suggestions dropdown.
(b) Add a Labs setting that can turn this feature on or
off.

Change-Id: Icae05b6b55f489278028e5af560d9b36014a0f59
diff --git a/src/com/android/browser/InstantSearchEngine.java b/src/com/android/browser/InstantSearchEngine.java
new file mode 100644
index 0000000..1d9bdd6
--- /dev/null
+++ b/src/com/android/browser/InstantSearchEngine.java
@@ -0,0 +1,407 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.browser;
+
+import com.google.android.collect.Maps;
+import com.google.common.collect.Lists;
+
+import com.android.browser.Controller;
+import com.android.browser.R;
+import com.android.browser.UI.DropdownChangeListener;
+import com.android.browser.search.DefaultSearchEngine;
+import com.android.browser.search.SearchEngine;
+
+import android.app.SearchManager;
+import android.content.Context;
+import android.database.AbstractCursor;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.LruCache;
+import android.webkit.SearchBox;
+import android.webkit.WebView;
+
+import java.util.Collections;
+import java.util.List;
+
+public class InstantSearchEngine implements SearchEngine, DropdownChangeListener {
+    private static final String TAG = "Browser.InstantSearchEngine";
+    private static final boolean DBG = false;
+
+    private Controller mController;
+    private SearchBox mSearchBox;
+    private final BrowserSearchboxListener mListener = new BrowserSearchboxListener();
+    private int mHeight;
+
+    private String mInstantBaseUrl;
+    private final Context mContext;
+    // Used for startSearch( ) calls if for some reason instant
+    // is off, or no searchbox is present.
+    private final SearchEngine mWrapped;
+
+    public InstantSearchEngine(Context context, SearchEngine wrapped) {
+        mContext = context;
+        mWrapped = wrapped;
+    }
+
+    public void setController(Controller controller) {
+        mController = controller;
+    }
+
+    @Override
+    public String getName() {
+        return SearchEngine.GOOGLE;
+    }
+
+    @Override
+    public CharSequence getLabel() {
+        return mContext.getResources().getString(R.string.instant_search_label);
+    }
+
+    @Override
+    public void startSearch(Context context, String query, Bundle appData, String extraData) {
+        if (DBG) Log.d(TAG, "startSearch(" + query + ")");
+
+        switchSearchboxIfNeeded();
+
+        // If for some reason we are in a bad state, ensure that the
+        // user gets default search results at the very least.
+        if (mSearchBox == null & !isInstantPage()) {
+            mWrapped.startSearch(context, query, appData, extraData);
+            return;
+        }
+
+        mSearchBox.setQuery(query);
+        mSearchBox.setVerbatim(true);
+        mSearchBox.onsubmit();
+    }
+
+    private final class BrowserSearchboxListener implements SearchBox.SearchBoxListener {
+        /*
+         * The maximum number of out of order suggestions we accept
+         * before giving up the wait.
+         */
+        private static final int MAX_OUT_OF_ORDER = 5;
+
+        /*
+         * We wait for suggestions in increments of 600ms. This is primarily to
+         * guard against suggestions arriving out of order.
+         */
+        private static final int WAIT_INCREMENT_MS = 600;
+
+        /*
+         * A cache of suggestions received, keyed by the queries they were
+         * received for.
+         */
+        private final LruCache<String, List<String>> mSuggestions =
+                new LruCache<String, List<String>>(20);
+
+        /*
+         * The last set of suggestions received. We use this reduce UI flicker
+         * in case there is a delay in recieving suggestions.
+         */
+        private List<String> mLatestSuggestion = Collections.emptyList();
+
+        @Override
+        public synchronized void onSuggestionsReceived(String query, List<String> suggestions) {
+            if (DBG) Log.d(TAG, "onSuggestionsReceived(" + query + ")");
+
+            if (!TextUtils.isEmpty(query)) {
+                mSuggestions.put(query, suggestions);
+                mLatestSuggestion = suggestions;
+            }
+
+            notifyAll();
+        }
+
+        public synchronized List<String> tryWaitForSuggestions(String query) {
+            if (DBG) Log.d(TAG, "tryWait(" + query + ")");
+
+            int numWaitReturns = 0;
+
+            // This slightly unusual waiting construct is used to safeguard
+            // to some extent against suggestions arriving out of order. We
+            // wait for upto 5 notifyAll( ) calls to check if we received
+            // suggestions for a given query.
+            while (mSuggestions.get(query) == null)  {
+                try {
+                    wait(WAIT_INCREMENT_MS);
+                    ++numWaitReturns;
+                    if (numWaitReturns > MAX_OUT_OF_ORDER) {
+                        // We've waited too long for suggestions to be returned.
+                        // return the last available suggestion.
+                        break;
+                    }
+                } catch (InterruptedException e) {
+                    return Collections.emptyList();
+                }
+            }
+
+            List<String> suggestions = mSuggestions.get(query);
+            if (suggestions == null) {
+                return mLatestSuggestion;
+            }
+
+            return suggestions;
+        }
+
+        public synchronized void clear() {
+            mSuggestions.evictAll();
+        }
+    }
+
+    private WebView getCurrentWebview() {
+        if (mController != null) {
+            return mController.getTabControl().getCurrentTopWebView();
+        }
+
+        return null;
+    }
+
+    /**
+     * Attaches the searchbox to the right browser page, i.e, the currently
+     * visible tab.
+     */
+    private void switchSearchboxIfNeeded() {
+        final SearchBox searchBox = getCurrentWebview().getSearchBox();
+        if (searchBox != mSearchBox) {
+            if (mSearchBox != null) {
+                mSearchBox.removeSearchBoxListener(mListener);
+                mListener.clear();
+            }
+            mSearchBox = searchBox;
+            mSearchBox.addSearchBoxListener(mListener);
+        }
+    }
+
+    private boolean isInstantPage() {
+        String currentUrl = getCurrentWebview().getUrl();
+
+        if (currentUrl != null) {
+            Uri uri = Uri.parse(currentUrl);
+            final String host = uri.getHost();
+            final String path = uri.getPath();
+
+            // Is there a utility class that does this ?
+            if (path != null && host != null) {
+                return host.startsWith("www.google.") &&
+                        (path.startsWith("/search") || path.startsWith("/webhp"));
+            }
+            return false;
+        }
+
+        return false;
+    }
+
+    private void loadInstantPage() {
+        mController.getActivity().runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                getCurrentWebview().loadUrl(getInstantBaseUrl());
+            }
+        });
+    }
+
+    /**
+     * Queries for a given search term and returns a cursor containing
+     * suggestions ordered by best match.
+     */
+    @Override
+    public Cursor getSuggestions(Context context, String query) {
+        if (DBG) Log.d(TAG, "getSuggestions(" + query + ")");
+        if (query == null) {
+            return null;
+        }
+
+        if (!isInstantPage()) {
+            loadInstantPage();
+        }
+
+        switchSearchboxIfNeeded();
+
+        mController.registerDropdownChangeListener(this);
+
+        mSearchBox.setDimensions(0, 0, 0, mHeight);
+        mSearchBox.onresize();
+
+        if (TextUtils.isEmpty(query)) {
+            // To force the SRP to render an empty (no results) page.
+            mSearchBox.setVerbatim(true);
+        } else {
+            mSearchBox.setVerbatim(false);
+        }
+        mSearchBox.setQuery(query);
+        mSearchBox.onchange();
+
+        // Don't bother waiting for suggestions for an empty query. We still
+        // set the query so that the SRP clears itself.
+        if (TextUtils.isEmpty(query)) {
+            return new SuggestionsCursor(Collections.<String>emptyList());
+        } else {
+            return new SuggestionsCursor(mListener.tryWaitForSuggestions(query));
+        }
+    }
+
+    @Override
+    public boolean supportsSuggestions() {
+        return true;
+    }
+
+    @Override
+    public void close() {
+        if (mController != null) {
+            mController.registerDropdownChangeListener(null);
+        }
+        if (mSearchBox != null) {
+            mSearchBox.removeSearchBoxListener(mListener);
+        }
+        mListener.clear();
+        mWrapped.close();
+    }
+
+    @Override
+    public boolean supportsVoiceSearch() {
+        return false;
+    }
+
+    @Override
+    public String toString() {
+        return "InstantSearchEngine {" + hashCode() + "}";
+    }
+
+    @Override
+    public boolean wantsEmptyQuery() {
+        return true;
+    }
+
+    private int rescaleHeight(int height) {
+        final float scale = getCurrentWebview().getScale();
+        if (scale != 0) {
+            return (int) (height / scale);
+        }
+
+        return height;
+    }
+
+    @Override
+    public void onNewDropdownDimensions(int height) {
+        final int rescaledHeight = rescaleHeight(height);
+
+        if (rescaledHeight != mHeight) {
+            mHeight = rescaledHeight;
+            mSearchBox.setDimensions(0, 0, 0, rescaledHeight);
+            mSearchBox.onresize();
+        }
+    }
+
+    private String getInstantBaseUrl() {
+        if (mInstantBaseUrl == null) {
+            String url = mContext.getResources().getString(R.string.instant_base);
+            if (url.indexOf("{CID}") != -1) {
+                url = url.replace("{CID}",
+                        BrowserProvider.getClientId(mContext.getContentResolver()));
+            }
+            mInstantBaseUrl = url;
+        }
+
+        return mInstantBaseUrl;
+    }
+
+    // Indices of the columns in the below arrays.
+    private static final int COLUMN_INDEX_ID = 0;
+    private static final int COLUMN_INDEX_QUERY = 1;
+    private static final int COLUMN_INDEX_ICON = 2;
+    private static final int COLUMN_INDEX_TEXT_1 = 3;
+
+    private static final String[] COLUMNS_WITHOUT_DESCRIPTION = new String[] {
+        "_id",
+        SearchManager.SUGGEST_COLUMN_QUERY,
+        SearchManager.SUGGEST_COLUMN_ICON_1,
+        SearchManager.SUGGEST_COLUMN_TEXT_1,
+    };
+
+    private static class SuggestionsCursor extends AbstractCursor {
+        private final List<String> mSuggestions;
+
+        public SuggestionsCursor(List<String> suggestions) {
+            mSuggestions = suggestions;
+        }
+
+        @Override
+        public int getCount() {
+            return mSuggestions.size();
+        }
+
+        @Override
+        public String[] getColumnNames() {
+            return COLUMNS_WITHOUT_DESCRIPTION;
+        }
+
+        private String format(String suggestion) {
+            if (TextUtils.isEmpty(suggestion)) {
+                return "";
+            }
+            return suggestion;
+        }
+
+        @Override
+        public String getString(int column) {
+            if (mPos >= 0 && mPos < mSuggestions.size()) {
+              if ((column == COLUMN_INDEX_QUERY) || (column == COLUMN_INDEX_TEXT_1)) {
+                  return format(mSuggestions.get(mPos));
+              } else if (column == COLUMN_INDEX_ICON) {
+                  return String.valueOf(R.drawable.magnifying_glass);
+              }
+            }
+            return null;
+        }
+
+        @Override
+        public double getDouble(int column) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public float getFloat(int column) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getInt(int column) {
+            if (column == COLUMN_INDEX_ID) {
+                return mPos;
+            }
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public long getLong(int column) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public short getShort(int column) {
+          throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isNull(int column) {
+            throw new UnsupportedOperationException();
+        }
+    }
+}