Bug 882185 - Restore opt-in search suggestions UI. r=lucasr
authorBrian Nicholson <bnicholson@mozilla.com>
Thu, 25 Jul 2013 11:08:37 -0700
changeset 143442 2097a11d890238b229d6ad0345090a6f5e334e81
parent 143441 87ed28075738caef0ee39694def5e5a7a2102035
child 143443 635b940aae8df09d3855203f5c55034a7a905c4f
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)
reviewerslucasr
bugs882185
milestone25.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 882185 - Restore opt-in search suggestions UI. r=lucasr
mobile/android/base/Makefile.in
mobile/android/base/home/BrowserSearch.java
mobile/android/base/home/SearchEngineRow.java
mobile/android/base/resources/layout/browser_search.xml
mobile/android/base/resources/layout/home_suggestion_prompt.xml
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -448,30 +448,32 @@ endif
 
 RES_LAYOUT = \
   $(SYNC_RES_LAYOUT) \
   res/layout/arrow_popup.xml \
   res/layout/autocomplete_list.xml \
   res/layout/autocomplete_list_item.xml \
   res/layout/bookmark_edit.xml \
   res/layout/bookmark_folder_row.xml \
+  res/layout/browser_search.xml \
   res/layout/browser_toolbar.xml \
   res/layout/datetime_picker.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_bookmarks_page.xml \
   res/layout/home_item_row.xml \
   res/layout/home_header_row.xml \
   res/layout/home_history_page.xml \
   res/layout/home_last_tabs_page.xml \
   res/layout/home_list_with_title.xml \
   res/layout/home_search_item_row.xml \
+  res/layout/home_suggestion_prompt.xml \
   res/layout/home_visited_page.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_item_action_view.xml \
   res/layout/menu_popup.xml \
   res/layout/notification_icon_text.xml \
--- a/mobile/android/base/home/BrowserSearch.java
+++ b/mobile/android/base/home/BrowserSearch.java
@@ -3,16 +3,17 @@
  * 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.home;
 
 import org.mozilla.gecko.AutocompleteHandler;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.db.BrowserDB.URLColumns;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader;
 import org.mozilla.gecko.util.GeckoEventListener;
@@ -32,19 +33,27 @@ import android.os.Bundle;
 import android.support.v4.app.LoaderManager.LoaderCallbacks;
 import android.support.v4.content.AsyncTaskLoader;
 import android.support.v4.content.Loader;
 import android.support.v4.widget.SimpleCursorAdapter;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
+import android.view.View.OnClickListener;
 import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.WindowManager.LayoutParams;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
 import android.widget.AdapterView;
+import android.widget.LinearLayout;
 import android.widget.ListView;
+import android.widget.TextView;
 
 import java.util.ArrayList;
 import java.util.List;
 
 /**
  * Fragment that displays frecency search results in a ListView.
  */
 public class BrowserSearch extends HomeFragment
@@ -66,27 +75,33 @@ public class BrowserSearch extends HomeF
 
     // Maximum number of results returned by the suggestion client
     private static final int SUGGESTION_MAX = 3;
 
     // The maximum number of rows deep in a search we'll dig
     // for an autocomplete result
     private static final int MAX_AUTOCOMPLETE_SEARCH = 20;
 
+    // Duration for fade-in animation
+    private static final int ANIMATION_DURATION = 250;
+
     // 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.
+    // The view shown by the fragment
+    private LinearLayout mView;
+
+    // The list showing search results
     private ListView mList;
 
     // Client that performs search suggestion queries
-    private SuggestClient mSuggestClient;
+    private volatile 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
@@ -105,16 +120,22 @@ public class BrowserSearch extends HomeF
     private OnUrlOpenListener mUrlOpenListener;
 
     // On search listener
     private OnSearchListener mSearchListener;
 
     // On edit suggestion listener
     private OnEditSuggestionListener mEditSuggestionListener;
 
+    // Whether the suggestions will fade in when shown
+    private boolean mAnimateSuggestions;
+
+    // Opt-in prompt view for search suggestions
+    private View mSuggestionsOptInPrompt;
+
     public interface OnSearchListener {
         public void onSearch(String engineId, String text);
     }
 
     public interface OnEditSuggestionListener {
         public void onEditSuggestion(String suggestion);
     }
 
@@ -122,36 +143,16 @@ public class BrowserSearch extends HomeF
         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");
@@ -184,18 +185,33 @@ public class BrowserSearch extends HomeF
         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 HomeListView(container.getContext());
-        return mList;
+        mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
+        mList = (ListView) mView.findViewById(R.id.home_list_view);
+
+        return mView;
+    }
+
+    @Override
+    public void onDestroyView() {
+        super.onDestroyView();
+
+        unregisterEventListener("SearchEngines:Data");
+
+        mView = null;
+        mList = null;
+        mSearchEngines = null;
+        mSuggestionsOptInPrompt = null;
+        mSuggestClient = null;
     }
 
     @Override
     public void onViewCreated(View view, Bundle savedInstanceState) {
         super.onViewCreated(view, savedInstanceState);
 
         mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
             @Override
@@ -206,16 +222,20 @@ public class BrowserSearch extends HomeF
                 }
 
                 final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
                 mUrlOpenListener.onUrlOpen(url);
             }
         });
 
         registerForContextMenu(mList);
+        registerEventListener("SearchEngines:Data");
+
+        mSearchEngines = new ArrayList<SearchEngine>();
+        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:Get", null));
     }
 
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
         super.onActivityCreated(savedInstanceState);
 
         // Intialize the search adapter
         mAdapter = new SearchAdapter(getActivity());
@@ -307,21 +327,30 @@ public class BrowserSearch extends HomeF
         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();
+        if (mSearchEngines != null) {
+            mSearchEngines.get(0).suggestions = suggestions;
+            mAdapter.notifyDataSetChanged();
+        }
     }
 
     private void setSearchEngines(JSONObject data) {
+        // This method is called via a Runnable posted from the Gecko thread, so
+        // it's possible the fragment and/or its view has been destroyed by the
+        // time we get here. If so, just abort.
+        if (mView == null) {
+            return;
+        }
+
         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");
@@ -353,24 +382,129 @@ public class BrowserSearch extends HomeF
             }
 
             mSearchEngines = searchEngines;
 
             if (mAdapter != null) {
                 mAdapter.notifyDataSetChanged();
             }
 
-            // FIXME: restore suggestion opt-in UI
+            // Show suggestions opt-in prompt only if user hasn't been prompted
+            // and we're not on a private browsing tab.
+            if (!suggestionsPrompted && mSuggestClient != null) {
+                showSuggestionsOptIn();
+            }
         } catch (JSONException e) {
             Log.e(LOGTAG, "Error getting search engine JSON", e);
         }
 
         filterSuggestions();
     }
 
+    private void showSuggestionsOptIn() {
+        mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate();
+
+        TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title);
+        promptText.setText(getResources().getString(R.string.suggestions_prompt, mSearchEngines.get(0).name));
+
+        final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes);
+        final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no);
+
+        final OnClickListener listener = new OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                // Prevent the buttons from being clicked multiple times (bug 816902)
+                yesButton.setOnClickListener(null);
+                noButton.setOnClickListener(null);
+
+                setSuggestionsEnabled(v == yesButton);
+            }
+        };
+
+        yesButton.setOnClickListener(listener);
+        noButton.setOnClickListener(listener);
+    }
+
+    private void setSuggestionsEnabled(final boolean enabled) {
+        // Clicking the yes/no buttons quickly can cause the click events be
+        // queued before the listeners are removed above, so it's possible
+        // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt
+        // can be null if this happens (bug 828480).
+        if (mSuggestionsOptInPrompt == null) {
+            return;
+        }
+
+        // Make suggestions appear immediately after the user opts in
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                SuggestClient client = mSuggestClient;
+                if (client != null) {
+                    client.query(mSearchTerm);
+                }
+            }
+        });
+
+        // Pref observer in gecko will also set prompted = true
+        PrefsHelper.setPref("browser.search.suggest.enabled", enabled);
+
+        TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0);
+        slideAnimation.setDuration(ANIMATION_DURATION);
+        slideAnimation.setInterpolator(new AccelerateInterpolator());
+        slideAnimation.setFillAfter(true);
+        final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+
+        TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight());
+        shrinkAnimation.setDuration(ANIMATION_DURATION);
+        shrinkAnimation.setFillAfter(true);
+        shrinkAnimation.setStartOffset(slideAnimation.getDuration());
+        shrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
+            @Override
+            public void onAnimationStart(Animation a) {
+                // Increase the height of the view so a gap isn't shown during animation
+                mView.getLayoutParams().height = mView.getHeight() +
+                        mSuggestionsOptInPrompt.getHeight();
+                mView.requestLayout();
+            }
+
+            @Override
+            public void onAnimationRepeat(Animation a) {}
+
+            @Override
+            public void onAnimationEnd(Animation a) {
+                // Removing the view immediately results in a NPE in
+                // dispatchDraw(), possibly because this callback executes
+                // before drawing is finished. Posting this as a Runnable fixes
+                // the issue.
+                mView.post(new Runnable() {
+                    @Override
+                    public void run() {
+                        mView.removeView(mSuggestionsOptInPrompt);
+                        mList.clearAnimation();
+                        mSuggestionsOptInPrompt = null;
+
+                        if (enabled) {
+                            // Reset the view height
+                            mView.getLayoutParams().height = LayoutParams.MATCH_PARENT;
+
+                            mSuggestionsEnabled = enabled;
+                            mAnimateSuggestions = true;
+                            mAdapter.notifyDataSetChanged();
+                            filterSuggestions();
+                        }
+                    }
+                });
+            }
+        });
+
+        prompt.startAnimation(slideAnimation);
+        mSuggestionsOptInPrompt.startAnimation(shrinkAnimation);
+        mList.startAnimation(shrinkAnimation);
+    }
+
     private void registerEventListener(String eventName) {
         GeckoAppShell.registerEventListener(eventName, this);
     }
 
     private void unregisterEventListener(String eventName) {
         GeckoAppShell.unregisterEventListener(eventName, this);
     }
 
@@ -523,17 +657,22 @@ public class BrowserSearch extends HomeF
                     row.setOnEditSuggestionListener(mEditSuggestionListener);
                 } else {
                     row = (SearchEngineRow) convertView;
                 }
 
                 row.setSearchTerm(mSearchTerm);
 
                 final SearchEngine engine = mSearchEngines.get(getEngineIndex(position));
-                row.updateFromSearchEngine(engine);
+                final boolean animate = (mAnimateSuggestions && engine.suggestions.size() > 0);
+                row.updateFromSearchEngine(engine, animate);
+                if (animate) {
+                    // Only animate suggestions the first time they are shown
+                    mAnimateSuggestions = false;
+                }
 
                 return row;
             } else {
                 final TwoLinePageRow row;
                 if (convertView == null) {
                     row = (TwoLinePageRow) mInflater.inflate(R.layout.home_item_row, mList, false);
                 } else {
                     row = (TwoLinePageRow) convertView;
@@ -628,16 +767,19 @@ public class BrowserSearch extends HomeF
                 break;
             }
         }
     }
 
     private class SuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
         @Override
         public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+            // mSuggestClient is set to null in onDestroyView(), so using it
+            // safely here relies on the fact that onCreateLoader() is called
+            // synchronously in restartLoader().
             return new SuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm);
         }
 
         @Override
         public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
             setSuggestions(suggestions);
         }
 
--- a/mobile/android/base/home/SearchEngineRow.java
+++ b/mobile/android/base/home/SearchEngineRow.java
@@ -3,36 +3,34 @@
  * 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.home;
 
 import org.mozilla.gecko.AnimatedHeightLayout;
 import org.mozilla.gecko.FlowLayout;
 import org.mozilla.gecko.R;
-import org.mozilla.gecko.Tab;
-import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.home.BrowserSearch.OnEditSuggestionListener;
 import org.mozilla.gecko.home.BrowserSearch.OnSearchListener;
 import org.mozilla.gecko.home.HomePager.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.view.animation.AlphaAnimation;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
 class SearchEngineRow extends AnimatedHeightLayout {
+    // Duration for fade-in animation
+    private static final int ANIMATION_DURATION = 250;
 
     // Inner views
     private final FlowLayout mSuggestionView;
     private final FaviconView mIconView;
     private final LinearLayout mUserEnteredView;
     private final TextView mUserEnteredTextView;
 
     // Inflater used when updating from suggestions
@@ -130,17 +128,17 @@ class SearchEngineRow extends AnimatedHe
     public void setOnSearchListener(OnSearchListener listener) {
         mSearchListener = listener;
     }
 
     public void setOnEditSuggestionListener(OnEditSuggestionListener listener) {
         mEditSuggestionListener = listener;
     }
 
-    public void updateFromSearchEngine(SearchEngine searchEngine) {
+    public void updateFromSearchEngine(SearchEngine searchEngine, boolean animate) {
         // 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();
@@ -163,16 +161,23 @@ class SearchEngineRow extends AnimatedHe
                         (ImageView) suggestionItem.findViewById(R.id.suggestion_magnifier);
                 magnifier.setVisibility(View.GONE);
 
                 mSuggestionView.addView(suggestionItem);
             }
 
             final String suggestion = mSearchEngine.suggestions.get(i);
             setSuggestionOnView(suggestionItem, suggestion);
+
+            if (animate) {
+                AlphaAnimation anim = new AlphaAnimation(0, 1);
+                anim.setDuration(ANIMATION_DURATION);
+                anim.setStartOffset(i * ANIMATION_DURATION);
+                suggestionItem.startAnimation(anim);
+            }
         }
 
         // 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/browser_search.xml
@@ -0,0 +1,22 @@
+<?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/. -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:orientation="vertical"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent">
+
+    <ViewStub android:id="@+id/suggestions_opt_in_prompt"
+              android:layout_width="match_parent"
+              android:layout_height="wrap_content"
+              android:layout="@layout/home_suggestion_prompt" />
+
+    <org.mozilla.gecko.home.HomeListView
+            android:id="@+id/home_list_view"
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            android:layout_weight="1" />
+
+</LinearLayout>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/home_suggestion_prompt.xml
@@ -0,0 +1,57 @@
+<?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/. -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+             android:layout_width="match_parent"
+             android:layout_height="wrap_content"
+             android:background="@drawable/url_bar_bg">
+
+    <LinearLayout android:id="@+id/prompt"
+                  android:focusable="true"
+                  android:layout_width="match_parent"
+                  android:layout_height="wrap_content"
+                  android:orientation="horizontal"
+                  android:minHeight="@dimen/search_row_height"
+                  android:gravity="center_vertical"
+                  android:padding="10dip">
+
+        <TextView android:id="@+id/suggestions_prompt_title"
+                  android:layout_height="wrap_content"
+                  android:layout_width="0dp"
+                  android:textColor="@color/url_bar_title"
+                  android:layout_marginLeft="6dip"
+                  android:textSize="14sp"
+                  android:layout_weight="1" />
+
+        <TextView android:id="@+id/suggestions_prompt_yes"
+                  android:layout_height="wrap_content"
+                  android:layout_width="wrap_content"
+                  android:textSize="14sp"
+                  android:layout_marginLeft="15dip"
+                  android:background="@drawable/suggestion_selector"
+                  android:paddingLeft="15dp"
+                  android:paddingRight="15dp"
+                  android:paddingTop="7dp"
+                  android:paddingBottom="7dp"
+                  android:focusable="true"
+                  android:text="@string/button_yes" />
+
+        <TextView android:id="@+id/suggestions_prompt_no"
+                  android:layout_height="wrap_content"
+                  android:layout_width="wrap_content"
+                  android:textSize="14sp"
+                  android:layout_marginLeft="6dip"
+                  android:background="@drawable/suggestion_selector"
+                  android:nextFocusRight="@+id/suggestions_prompt_no"
+                  android:paddingLeft="15dp"
+                  android:paddingRight="15dp"
+                  android:paddingTop="7dp"
+                  android:paddingBottom="7dp"
+                  android:focusable="true"
+                  android:text="@string/button_no" />
+
+    </LinearLayout>
+
+</FrameLayout>