Bug 871650 - Restore search suggestions support (r=bnicholson)
authorLucas Rocha <lucasr@mozilla.com>
Tue, 11 Jun 2013 18:01:35 +0100
changeset 143324 ea79867c3ffda6170afe96fb8a8bdd2085548a84
parent 143323 6509a6fd9bd55f733e854848cfc6f92999ca4fde
child 143325 478e2819dbf732bd594e3fb8c19e2a14234a3c6d
push id25130
push userlrocha@mozilla.com
push dateWed, 21 Aug 2013 09:41:27 +0000
treeherdermozilla-central@b2486721572e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbnicholson
bugs871650
milestone24.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 871650 - Restore search suggestions support (r=bnicholson)
mobile/android/base/BrowserApp.java
mobile/android/base/BrowserSearch.java
mobile/android/base/BrowserToolbar.java
mobile/android/base/Makefile.in
mobile/android/base/SearchEngineRow.java
mobile/android/base/resources/layout/home_search_item_row.xml
mobile/android/base/resources/layout/search_engine_row.xml
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -69,16 +69,18 @@ import java.net.URL;
 import java.util.EnumSet;
 import java.util.Vector;
 
 abstract public class BrowserApp extends GeckoApp
                                  implements TabsPanel.TabsLayoutChangeListener,
                                             PropertyAnimator.PropertyAnimationListener,
                                             View.OnKeyListener,
                                             GeckoLayerClient.OnMetricsChangedListener,
+                                            BrowserSearch.OnSearchListener,
+                                            BrowserSearch.OnEditSuggestionListener,
                                             BrowserSearch.OnUrlOpenListener,
                                             HomePager.OnUrlOpenListener {
     private static final String LOGTAG = "GeckoBrowserApp";
 
     private static final String PREF_CHROME_DYNAMICTOOLBAR = "browser.chrome.dynamictoolbar";
 
     private static final String ABOUT_HOME = "about:home";
 
@@ -1124,24 +1126,28 @@ abstract public class BrowserApp extends
 
     @Override
     public void onSaveInstanceState(Bundle outState) {
         super.onSaveInstanceState(outState);
         outState.putBoolean(STATE_DYNAMIC_TOOLBAR_ENABLED, mDynamicToolbarEnabled);
     }
 
     private void openUrl(String url) {
+        openUrl(url, null);
+    }
+
+    private void openUrl(String url, String searchEngine) {
         mBrowserToolbar.setProgressVisibility(true);
 
         int flags = Tabs.LOADURL_NONE;
         if (mBrowserToolbar.getEditingTarget() == EditingTarget.NEW_TAB) {
             flags |= Tabs.LOADURL_NEW_TAB;
         }
 
-        Tabs.getInstance().loadUrl(url, flags);
+        Tabs.getInstance().loadUrl(url, searchEngine, -1, flags);
 
         hideBrowserSearch();
         mBrowserToolbar.cancelEdit();
     }
 
     /* Favicon methods */
     private void loadFavicon(final Tab tab) {
         maybeCancelFaviconLoad(tab);
@@ -1819,16 +1825,28 @@ abstract public class BrowserApp extends
     }
 
     // (HomePager|BrowserSearch).OnUrlOpenListener
     @Override
     public void onUrlOpen(String url) {
         openUrl(url);
     }
 
+    // BrowserSearch.OnSearchListener
+    @Override
+    public void onSearch(SearchEngine engine, String text) {
+        openUrl(text, engine.name);
+    }
+
+    // BrowserSearch.OnEditSuggestionListener
+    @Override
+    public void onEditSuggestion(String suggestion) {
+        mBrowserToolbar.onEditSuggestion(suggestion);
+    }
+
     @Override
     public int getLayout() { return R.layout.gecko_app; }
 
     @Override
     protected String getDefaultProfileName() {
         String profile = GeckoProfile.findDefaultProfile(this);
         return (profile != null ? profile : "default");
     }
--- a/mobile/android/base/BrowserSearch.java
+++ b/mobile/android/base/BrowserSearch.java
@@ -4,97 +4,188 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.BrowserDB.URLColumns;
 import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.home.TwoLinePageRow;
+import org.mozilla.gecko.widget.FaviconView;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
 
 import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.os.Bundle;
 import android.content.res.Configuration;
 import android.support.v4.app.Fragment;
 import android.support.v4.app.LoaderManager;
 import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.AsyncTaskLoader;
 import android.support.v4.content.Loader;
 import android.text.TextUtils;
+import android.util.Log;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.LayoutInflater;
 import android.widget.AdapterView;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
 import android.widget.ListView;
 import android.widget.SimpleCursorAdapter;
+import android.widget.TextView;
 
 import java.util.ArrayList;
 
 /**
  * Fragment that displays frecency search results in a ListView.
  */
-public class BrowserSearch extends Fragment implements AdapterView.OnItemClickListener {
+public class BrowserSearch extends Fragment implements AdapterView.OnItemClickListener,
+                                                       GeckoEventListener {
+    // Logging tag name
+    private static final String LOGTAG = "GeckoBrowserSearch";
+
     // Cursor loader ID for search query
     private static final int SEARCH_LOADER_ID = 0;
 
     // Cursor loader ID for favicons query
     private static final int FAVICONS_LOADER_ID = 1;
 
+    // AsyncTask loader ID for suggestion query
+    private static final int SUGGESTION_LOADER_ID = 2;
+
     // Argument containing list of urls for the favicons loader
     private static final String FAVICONS_LOADER_URLS_ARG = "urls";
 
+    // Timeout for the suggestion client to respond
+    private static final int SUGGESTION_TIMEOUT = 3000;
+
+    // Maximum number of results returned by the suggestion client
+    private static final int SUGGESTION_MAX = 3;
+
     // Holds the current search term to use in the query
     private String mSearchTerm;
 
     // Adapter for the list of search results
     private SearchAdapter mAdapter;
 
     // The view shown by the fragment.
     private ListView mList;
 
+    // Client that performs search suggestion queries
+    private SuggestClient mSuggestClient;
+
+    // List of search engines from gecko
+    private ArrayList<SearchEngine> mSearchEngines;
+
+    // Whether search suggestions are enabled or not
+    private boolean mSuggestionsEnabled;
+
     // Callbacks used for the search and favicon cursor loaders
     private CursorLoaderCallbacks mCursorLoaderCallbacks;
 
+    // Callbacks used for the search suggestion loader
+    private SuggestionLoaderCallbacks mSuggestionLoaderCallbacks;
+
+    // Inflater used by the adapter
+    private LayoutInflater mInflater;
+
     // On URL open listener
     private OnUrlOpenListener mUrlOpenListener;
 
+    // On search listener
+    private OnSearchListener mSearchListener;
+
+    // On edit suggestion listener
+    private OnEditSuggestionListener mEditSuggestionListener;
+
     public interface OnUrlOpenListener {
         public void onUrlOpen(String url);
     }
 
+    public interface OnSearchListener {
+        public void onSearch(SearchEngine engine, String text);
+    }
+
+    public interface OnEditSuggestionListener {
+        public void onEditSuggestion(String suggestion);
+    }
+
     public static BrowserSearch newInstance() {
         return new BrowserSearch();
     }
 
     public BrowserSearch() {
         mSearchTerm = "";
     }
 
     @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        registerEventListener("SearchEngines:Data");
+
+        // The search engines list is reused beyond the life-cycle of
+        // this fragment.
+        if (mSearchEngines == null) {
+            mSearchEngines = new ArrayList<SearchEngine>();
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:Get", null));
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        unregisterEventListener("SearchEngines:Data");
+    }
+
+    @Override
     public void onAttach(Activity activity) {
         super.onAttach(activity);
 
         try {
             mUrlOpenListener = (OnUrlOpenListener) activity;
         } catch (ClassCastException e) {
             throw new ClassCastException(activity.toString()
                     + " must implement BrowserSearch.OnUrlOpenListener");
         }
+
+        try {
+            mSearchListener = (OnSearchListener) activity;
+        } catch (ClassCastException e) {
+            throw new ClassCastException(activity.toString()
+                    + " must implement BrowserSearch.OnSearchListener");
+        }
+
+        try {
+            mEditSuggestionListener = (OnEditSuggestionListener) activity;
+        } catch (ClassCastException e) {
+            throw new ClassCastException(activity.toString()
+                    + " must implement BrowserSearch.OnEditSuggestionListener");
+        }
+
+        mInflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
     }
 
     @Override
     public void onDetach() {
         super.onDetach();
 
         mUrlOpenListener = null;
+        mSearchListener = null;
+        mEditSuggestionListener = null;
     }
 
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         // All list views are styled to look the same with a global activity theme.
         // If the style of the list changes, inflate it from an XML.
         mList = new ListView(container.getContext());
         return mList;
@@ -110,22 +201,113 @@ public class BrowserSearch extends Fragm
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
         super.onActivityCreated(savedInstanceState);
 
         // Intialize the search adapter
         mAdapter = new SearchAdapter(getActivity());
         mList.setAdapter(mAdapter);
 
+        // Only create an instance when we need it
+        mSuggestionLoaderCallbacks = null;
+
+        // Create callbacks before the initial loader is started
         mCursorLoaderCallbacks = new CursorLoaderCallbacks();
 
         // Reconnect to the loader only if present
         getLoaderManager().initLoader(SEARCH_LOADER_ID, null, mCursorLoaderCallbacks);
     }
 
+    @Override
+    public void handleMessage(String event, final JSONObject message) {
+        if (event.equals("SearchEngines:Data")) {
+            ThreadUtils.postToUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    setSearchEngines(message);
+                }
+            });
+        }
+    }
+
+    private void filterSuggestions() {
+        if (mSuggestClient == null || !mSuggestionsEnabled) {
+            return;
+        }
+
+        if (mSuggestionLoaderCallbacks == null) {
+            mSuggestionLoaderCallbacks = new SuggestionLoaderCallbacks();
+        }
+
+        getLoaderManager().restartLoader(SUGGESTION_LOADER_ID, null, mSuggestionLoaderCallbacks);
+    }
+
+    private void setSuggestions(ArrayList<String> suggestions) {
+        mSearchEngines.get(0).suggestions = suggestions;
+        mAdapter.notifyDataSetChanged();
+    }
+
+    private void setSearchEngines(JSONObject data) {
+        try {
+            final JSONObject suggest = data.getJSONObject("suggest");
+            final String suggestEngine = suggest.optString("engine", null);
+            final String suggestTemplate = suggest.optString("template", null);
+            final boolean suggestionsPrompted = suggest.getBoolean("prompted");
+            final JSONArray engines = data.getJSONArray("searchEngines");
+
+            mSuggestionsEnabled = suggest.getBoolean("enabled");
+
+            ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>();
+            for (int i = 0; i < engines.length(); i++) {
+                final JSONObject engineJSON = engines.getJSONObject(i);
+                final String name = engineJSON.getString("name");
+                final String identifier = engineJSON.getString("identifier");
+                final String iconURI = engineJSON.getString("iconURI");
+                final Bitmap icon = BitmapUtils.getBitmapFromDataURI(iconURI);
+
+                if (name.equals(suggestEngine) && suggestTemplate != null) {
+                    // Suggest engine should be at the front of the list
+                    searchEngines.add(0, new SearchEngine(name, identifier, icon));
+
+                    // The only time Tabs.getInstance().getSelectedTab() should
+                    // be null is when we're restoring after a crash. We should
+                    // never restore private tabs when that happens, so it
+                    // should be safe to assume that null means non-private.
+                    Tab tab = Tabs.getInstance().getSelectedTab();
+                    if (tab == null || !tab.isPrivate()) {
+                        mSuggestClient = new SuggestClient(getActivity(), suggestTemplate,
+                                SUGGESTION_TIMEOUT, SUGGESTION_MAX);
+                    }
+                } else {
+                    searchEngines.add(new SearchEngine(name, identifier, icon));
+                }
+            }
+
+            mSearchEngines = searchEngines;
+
+            if (mAdapter != null) {
+                mAdapter.notifyDataSetChanged();
+            }
+
+            // FIXME: restore suggestion opt-in UI
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "Error getting search engine JSON", e);
+        }
+
+        filterSuggestions();
+    }
+
+    private void registerEventListener(String eventName) {
+        GeckoAppShell.getEventDispatcher().registerEventListener(eventName, this);
+    }
+
+    private void unregisterEventListener(String eventName) {
+        GeckoAppShell.getEventDispatcher().unregisterEventListener(eventName, this);
+    }
+
     private ArrayList<String> getUrlsWithoutFavicon(Cursor c) {
         ArrayList<String> urls = new ArrayList<String>();
 
         if (c == null || !c.moveToFirst()) {
             return urls;
         }
 
         final Favicons favicons = Favicons.getInstance();
@@ -163,18 +345,23 @@ public class BrowserSearch extends Fragm
 
         if (TextUtils.equals(mSearchTerm, searchTerm)) {
             return;
         }
 
         mSearchTerm = searchTerm;
 
         if (isVisible()) {
+            // The adapter depends on the search term to determine its number
+            // of items. Make it we notify the view about it.
+            mAdapter.notifyDataSetChanged();
+
+            // Restart loaders with the new search term
             getLoaderManager().restartLoader(SEARCH_LOADER_ID, null, mCursorLoaderCallbacks);
-
+            filterSuggestions();
         }
     }
 
     private static class SearchCursorLoader extends SimpleCursorLoader {
         // Max number of search results
         private static final int SEARCH_LIMIT = 100;
 
         // The target search term associated with the loader
@@ -235,40 +422,201 @@ public class BrowserSearch extends Fragm
                 }
 
                 favicon = favicons.scaleImage(favicon);
                 favicons.putFaviconInMemCache(url, favicon);
             } while (c.moveToNext());
         }
     }
 
+    private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> {
+        private final SuggestClient mSuggestClient;
+        private final String mSearchTerm;
+        private ArrayList<String> mSuggestions;
+
+        public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
+            super(context);
+            mSuggestClient = suggestClient;
+            mSearchTerm = searchTerm;
+            mSuggestions = null;
+        }
+
+        @Override
+        public ArrayList<String> loadInBackground() {
+            return mSuggestClient.query(mSearchTerm);
+        }
+
+        @Override
+        public void deliverResult(ArrayList<String> suggestions) {
+            mSuggestions = suggestions;
+
+            if (isStarted()) {
+                super.deliverResult(mSuggestions);
+            }
+        }
+
+        @Override
+        protected void onStartLoading() {
+            if (mSuggestions != null) {
+                deliverResult(mSuggestions);
+            }
+
+            if (takeContentChanged() || mSuggestions == null) {
+                forceLoad();
+            }
+        }
+
+        @Override
+        protected void onStopLoading() {
+            cancelLoad();
+        }
+
+        @Override
+        protected void onReset() {
+            super.onReset();
+
+            onStopLoading();
+            mSuggestions = null;
+        }
+    }
+
+    private class SearchEntryViewHolder {
+        public FlowLayout suggestionView;
+        public FaviconView iconView;
+        public LinearLayout userEnteredView;
+        public TextView userEnteredTextView;
+    }
+
     private class SearchAdapter extends SimpleCursorAdapter {
+        private static final int ROW_SEARCH = 0;
+        private static final int ROW_STANDARD = 1;
+        private static final int ROW_SUGGEST = 2;
+
+        private static final int ROW_TYPE_COUNT = 3;
+
         public SearchAdapter(Context context) {
             super(context, -1, null, new String[] {}, new int[] {});
         }
 
         @Override
-        public View getView(int position, View convertView, ViewGroup parent) {
-            final TwoLinePageRow row;
-            if (convertView == null) {
-                row = (TwoLinePageRow) LayoutInflater.from(getActivity()).inflate(R.layout.home_item_row, null);
-            } else {
-                row = (TwoLinePageRow) convertView;
+        public int getItemViewType(int position) {
+            final int engine = getEngineIndex(position);
+
+            if (engine == -1) {
+                return ROW_STANDARD;
+            } else if (engine == 0 && mSuggestionsEnabled) {
+                // Give suggestion views their own type to prevent them from
+                // sharing other recycled search engine views. Using other
+                // recycled views for the suggestion row can break animations
+                // (bug 815937).
+                return ROW_SUGGEST;
+            }
+
+            return ROW_SEARCH;
+        }
+
+        @Override
+        public int getViewTypeCount() {
+            // view can be either a standard awesomebar row, a search engine
+            // row, or a suggestion row
+            return ROW_TYPE_COUNT;
+        }
+
+        @Override
+        public boolean isEnabled(int position) {
+            // If the suggestion row only contains one item (the user-entered
+            // query), allow the entire row to be clickable; clicking the row
+            // has the same effect as clicking the single suggestion. If the
+            // row contains multiple items, clicking the row will do nothing.
+            final int index = getEngineIndex(position);
+            if (index != -1) {
+                return mSearchEngines.get(index).suggestions.isEmpty();
+            }
+
+            return true;
+        }
+
+        // Add the search engines to the number of reported results.
+        @Override
+        public int getCount() {
+            final int resultCount = super.getCount();
+
+            // Don't show search engines or suggestions if search field is empty
+            if (TextUtils.isEmpty(mSearchTerm)) {
+                return resultCount;
             }
 
-            final Cursor c = getCursor();
-            if (!c.moveToPosition(position)) {
-                throw new IllegalStateException("Couldn't move cursor to position " + position);
+            return resultCount + mSearchEngines.size();
+        }
+
+        @Override
+        public View getView(int position, View convertView, ViewGroup parent) {
+            final int type = getItemViewType(position);
+
+            if (type == ROW_SEARCH || type == ROW_SUGGEST) {
+                final SearchEngineRow row;
+                if (convertView == null) {
+                    row = (SearchEngineRow) mInflater.inflate(R.layout.home_search_item_row, mList, false);
+                    row.setOnUrlOpenListener(mUrlOpenListener);
+                    row.setOnSearchListener(mSearchListener);
+                    row.setOnEditSuggestionListener(mEditSuggestionListener);
+                } else {
+                    row = (SearchEngineRow) convertView;
+                }
+
+                row.setSearchTerm(mSearchTerm);
+
+                final SearchEngine engine = mSearchEngines.get(getEngineIndex(position));
+                row.updateFromSearchEngine(engine);
+
+                return row;
+            } else {
+                final TwoLinePageRow row;
+                if (convertView == null) {
+                    row = (TwoLinePageRow) mInflater.inflate(R.layout.home_item_row, mList, false);
+                } else {
+                    row = (TwoLinePageRow) convertView;
+                }
+
+                // Account for the search engines
+                position -= getSuggestEngineCount();
+
+                final Cursor c = getCursor();
+                if (!c.moveToPosition(position)) {
+                    throw new IllegalStateException("Couldn't move cursor to position " + position);
+                }
+
+                row.updateFromCursor(c);
+
+                // FIXME: show bookmark icon
+
+                return row;
+            }
+        }
+
+        private int getSuggestEngineCount() {
+            return (TextUtils.isEmpty(mSearchTerm) || mSuggestClient == null || !mSuggestionsEnabled) ? 0 : 1;
+        }
+
+        private int getEngineIndex(int position) {
+            final int resultCount = super.getCount();
+            final int suggestEngineCount = getSuggestEngineCount();
+
+            // Return suggest engine index
+            if (position < suggestEngineCount) {
+                return position;
             }
 
-            row.updateFromCursor(c);
+            // Not an engine
+            if (position - suggestEngineCount < resultCount) {
+                return -1;
+            }
 
-            // FIXME: show bookmark icon
-
-            return row;
+            // Return search engine index
+            return position - resultCount;
         }
     }
 
     private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
             switch(id) {
             case SEARCH_LOADER_ID:
@@ -316,9 +664,26 @@ public class BrowserSearch extends Fragm
                 break;
 
             case FAVICONS_LOADER_ID:
                 // Do nothing
                 break;
             }
         }
     }
+
+    private class SuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
+        @Override
+        public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+            return new SuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm);
+        }
+
+        @Override
+        public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+            setSuggestions(suggestions);
+        }
+
+        @Override
+        public void onLoaderReset(Loader<ArrayList<String>> loader) {
+            setSuggestions(new ArrayList<String>());
+        }
+    }
 }
\ No newline at end of file
--- a/mobile/android/base/BrowserToolbar.java
+++ b/mobile/android/base/BrowserToolbar.java
@@ -1023,16 +1023,28 @@ public class BrowserToolbar implements T
         visible &= !(url == null || (url.startsWith("about:") && 
                      !url.equals("about:blank"))) && !isEditing();
 
         if ((mShadow.getVisibility() == View.VISIBLE) != visible) {
             mShadow.setVisibility(visible ? View.VISIBLE : View.GONE);
         }
     }
 
+    public void onEditSuggestion(String suggestion) {
+        if (!isEditing()) {
+            return;
+        }
+
+        mUrlEditText.setText(suggestion);
+        mUrlEditText.setSelection(mUrlEditText.getText().length());
+        mUrlEditText.requestFocus();
+
+        showSoftInput();
+    }
+
     private void setTitle(CharSequence title) {
         mTitle.setText(title);
         mLayout.setContentDescription(title != null ? title : mTitle.getHint());
     }
 
     // Sets the toolbar title according to the selected tab, obeying the mShowUrl prference.
     private void updateTitle() {
         Tab tab = Tabs.getInstance().getSelectedTab();
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -132,16 +132,17 @@ FENNEC_JAVA_FILES = \
   PrivateDataPreference.java \
   PrivateTab.java \
   ProfileMigrator.java \
   Prompt.java \
   PromptInput.java \
   PromptService.java \
   Restarter.java \
   SearchEngine.java \
+  SearchEngineRow.java \
   sqlite/ByteBufferInputStream.java \
   sqlite/MatrixBlobCursor.java \
   sqlite/SQLiteBridge.java \
   sqlite/SQLiteBridgeException.java \
   ReaderModeUtils.java \
   RemoteTabs.java \
   RobocopAPI.java \
   ServiceNotificationClient.java \
@@ -449,31 +450,33 @@ RES_LAYOUT = \
   res/layout/datetime_picker.xml \
   res/layout/doorhangerpopup.xml \
   res/layout/doorhanger.xml \
   res/layout/doorhanger_button.xml \
   res/layout/find_in_page_content.xml \
   res/layout/font_size_preference.xml \
   res/layout/gecko_app.xml \
   res/layout/home_item_row.xml \
+  res/layout/home_search_item_row.xml \
   res/layout/web_app.xml \
   res/layout/launch_app_list.xml \
   res/layout/launch_app_listitem.xml \
   res/layout/menu_action_bar.xml \
   res/layout/menu_popup.xml \
   res/layout/notification_icon_text.xml \
   res/layout/notification_progress.xml \
   res/layout/notification_progress_text.xml \
   res/layout/site_setting_item.xml \
   res/layout/site_setting_title.xml \
   res/layout/setup_screen.xml \
   res/layout/shared_ui_components.xml \
   res/layout/site_identity_popup.xml \
   res/layout/remote_tabs_child.xml \
   res/layout/remote_tabs_group.xml \
+  res/layout/search_engine_row.xml \
   res/layout/tabs_panel.xml \
   res/layout/tabs_counter.xml \
   res/layout/tabs_panel_header.xml \
   res/layout/tabs_panel_indicator.xml \
   res/layout/tabs_item_cell.xml \
   res/layout/tabs_item_row.xml \
   res/layout/text_selection_handles.xml \
   res/layout/two_line_page_row.xml \
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/SearchEngineRow.java
@@ -0,0 +1,173 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.BrowserSearch.OnEditSuggestionListener;
+import org.mozilla.gecko.BrowserSearch.OnSearchListener;
+import org.mozilla.gecko.BrowserSearch.OnUrlOpenListener;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.widget.FaviconView;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnLongClickListener;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+class SearchEngineRow extends AnimatedHeightLayout {
+
+    // Inner views
+    private final FlowLayout mSuggestionView;
+    private final FaviconView mIconView;
+    private final LinearLayout mUserEnteredView;
+    private final TextView mUserEnteredTextView;
+
+    // Inflater used when updating from suggestions
+    private final LayoutInflater mInflater;
+
+    // Search engine associated with this view
+    private SearchEngine mSearchEngine;
+
+    // Event listeners for suggestion views
+    private final OnClickListener mClickListener;
+    private final OnLongClickListener mLongClickListener;
+
+    // On URL open listener
+    private OnUrlOpenListener mUrlOpenListener;
+
+    // On search listener
+    private OnSearchListener mSearchListener;
+
+    // On edit suggestion listener
+    private OnEditSuggestionListener mEditSuggestionListener;
+
+    public SearchEngineRow(Context context) {
+        this(context, null);
+    }
+
+    public SearchEngineRow(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public SearchEngineRow(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+
+        mClickListener = new OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                final String suggestion = getSuggestionTextFromView(v);
+
+                // If we're not clicking the user-entered view (the first suggestion item)
+                // and the search matches a URL pattern, go to that URL. Otherwise, do a
+                // search for the term.
+                if (v != mUserEnteredView && !StringUtils.isSearchQuery(suggestion, false)) {
+                    if (mUrlOpenListener != null) {
+                        mUrlOpenListener.onUrlOpen(suggestion);
+                    }
+                } else if (mSearchListener != null) {
+                    mSearchListener.onSearch(mSearchEngine, suggestion);
+                }
+            }
+        };
+
+        mLongClickListener = new OnLongClickListener() {
+            @Override
+            public boolean onLongClick(View v) {
+                if (mEditSuggestionListener != null) {
+                    final String suggestion = getSuggestionTextFromView(v);
+                    mEditSuggestionListener.onEditSuggestion(suggestion);
+                    return true;
+                }
+
+                return false;
+            }
+        };
+
+        mInflater = LayoutInflater.from(context);
+        mInflater.inflate(R.layout.search_engine_row, this);
+
+        mSuggestionView = (FlowLayout) findViewById(R.id.suggestion_layout);
+        mIconView = (FaviconView) findViewById(R.id.suggestion_icon);
+
+        // User-entered search term is first suggestion
+        mUserEnteredView = (LinearLayout) findViewById(R.id.suggestion_user_entered);
+        mUserEnteredView.setOnClickListener(mClickListener);
+
+        mUserEnteredTextView = (TextView) findViewById(R.id.suggestion_text);
+    }
+
+    private String getSuggestionTextFromView(View v) {
+        final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+        return suggestionText.getText().toString();
+    }
+
+    private void setSuggestionOnView(View v, String suggestion) {
+        final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+        suggestionText.setText(suggestion);
+    }
+
+    public void setSearchTerm(String searchTerm) {
+        mUserEnteredTextView.setText(searchTerm);
+    }
+
+    public void setOnUrlOpenListener(OnUrlOpenListener listener) {
+        mUrlOpenListener = listener;
+    }
+
+    public void setOnSearchListener(OnSearchListener listener) {
+        mSearchListener = listener;
+    }
+
+    public void setOnEditSuggestionListener(OnEditSuggestionListener listener) {
+        mEditSuggestionListener = listener;
+    }
+
+    public void updateFromSearchEngine(SearchEngine searchEngine) {
+        // Update search engine reference
+        mSearchEngine = searchEngine;
+
+        // Set the search engine icon (e.g., Google) for the row
+        mIconView.updateImage(mSearchEngine.icon, mSearchEngine.name);
+
+        // Add additional suggestions given by this engine
+        final int recycledSuggestionCount = mSuggestionView.getChildCount();
+        final int suggestionCount = mSearchEngine.suggestions.size();
+
+        for (int i = 0; i < suggestionCount; i++) {
+            final View suggestionItem;
+
+            // Reuse suggestion views from recycled view, if possible
+            if (i + 1 < recycledSuggestionCount) {
+                suggestionItem = mSuggestionView.getChildAt(i + 1);
+                suggestionItem.setVisibility(View.VISIBLE);
+            } else {
+                suggestionItem = mInflater.inflate(R.layout.awesomebar_suggestion_item, null);
+
+                suggestionItem.setOnClickListener(mClickListener);
+                suggestionItem.setOnLongClickListener(mLongClickListener);
+
+                final ImageView magnifier =
+                        (ImageView) suggestionItem.findViewById(R.id.suggestion_magnifier);
+                magnifier.setVisibility(View.GONE);
+
+                mSuggestionView.addView(suggestionItem);
+            }
+
+            final String suggestion = mSearchEngine.suggestions.get(i);
+            setSuggestionOnView(suggestionItem, suggestion);
+        }
+
+        // Hide extra suggestions that have been recycled
+        for (int i = suggestionCount + 1; i < recycledSuggestionCount; i++) {
+            mSuggestionView.getChildAt(i).setVisibility(View.GONE);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/home_search_item_row.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<org.mozilla.gecko.SearchEngineRow xmlns:android="http://schemas.android.com/apk/res/android"
+                                   android:layout_width="fill_parent"
+                                   android:layout_height="wrap_content"
+                                   android:minHeight="@dimen/page_row_height"
+                                   android:padding="7dp"/>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/search_engine_row.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+	<Gecko.FaviconView android:id="@+id/suggestion_icon"
+	                   android:layout_width="@dimen/favicon_bg"
+	                   android:layout_height="@dimen/favicon_bg"
+	                   android:layout_marginLeft="6dip"
+	                   android:layout_marginRight="6dip"
+	                   android:layout_centerVertical="true"
+	                   android:minWidth="@dimen/favicon_bg"
+	                   android:minHeight="@dimen/favicon_bg"/>
+
+	<org.mozilla.gecko.FlowLayout android:id="@+id/suggestion_layout"
+	                              android:layout_toRightOf="@id/suggestion_icon"
+	                              android:layout_width="wrap_content"
+	                              android:layout_height="wrap_content">
+
+	    <include layout="@layout/awesomebar_suggestion_item"
+	             android:id="@+id/suggestion_user_entered"/>
+
+	</org.mozilla.gecko.FlowLayout>
+
+</merge>
\ No newline at end of file