mobile/android/base/home/BrowserSearch.java

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
     2  * This Source Code Form is subject to the terms of the Mozilla Public
     3  * License, v. 2.0. If a copy of the MPL was not distributed with this
     4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     6 package org.mozilla.gecko.home;
     8 import org.mozilla.gecko.GeckoAppShell;
     9 import org.mozilla.gecko.GeckoEvent;
    10 import org.mozilla.gecko.PrefsHelper;
    11 import org.mozilla.gecko.R;
    12 import org.mozilla.gecko.Tab;
    13 import org.mozilla.gecko.Tabs;
    14 import org.mozilla.gecko.Telemetry;
    15 import org.mozilla.gecko.TelemetryContract;
    16 import org.mozilla.gecko.db.BrowserDB.URLColumns;
    17 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
    18 import org.mozilla.gecko.home.SearchEngine;
    19 import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader;
    20 import org.mozilla.gecko.mozglue.RobocopTarget;
    21 import org.mozilla.gecko.toolbar.AutocompleteHandler;
    22 import org.mozilla.gecko.util.GeckoEventListener;
    23 import org.mozilla.gecko.util.StringUtils;
    24 import org.mozilla.gecko.util.ThreadUtils;
    26 import org.json.JSONArray;
    27 import org.json.JSONException;
    28 import org.json.JSONObject;
    30 import android.app.Activity;
    31 import android.content.Context;
    32 import android.database.Cursor;
    33 import android.net.Uri;
    34 import android.os.Bundle;
    35 import android.support.v4.app.LoaderManager.LoaderCallbacks;
    36 import android.support.v4.content.AsyncTaskLoader;
    37 import android.support.v4.content.Loader;
    38 import android.text.TextUtils;
    39 import android.util.AttributeSet;
    40 import android.util.Log;
    41 import android.view.LayoutInflater;
    42 import android.view.MotionEvent;
    43 import android.view.View;
    44 import android.view.View.OnClickListener;
    45 import android.view.ViewGroup;
    46 import android.view.ViewStub;
    47 import android.view.WindowManager;
    48 import android.view.WindowManager.LayoutParams;
    49 import android.view.animation.AccelerateInterpolator;
    50 import android.view.animation.Animation;
    51 import android.view.animation.TranslateAnimation;
    52 import android.widget.AdapterView;
    53 import android.widget.LinearLayout;
    54 import android.widget.ListView;
    55 import android.widget.TextView;
    57 import java.util.ArrayList;
    58 import java.util.EnumSet;
    60 /**
    61  * Fragment that displays frecency search results in a ListView.
    62  */
    63 public class BrowserSearch extends HomeFragment
    64                            implements GeckoEventListener {
    65     // Logging tag name
    66     private static final String LOGTAG = "GeckoBrowserSearch";
    68     // Cursor loader ID for search query
    69     private static final int LOADER_ID_SEARCH = 0;
    71     // AsyncTask loader ID for suggestion query
    72     private static final int LOADER_ID_SUGGESTION = 1;
    74     // Timeout for the suggestion client to respond
    75     private static final int SUGGESTION_TIMEOUT = 3000;
    77     // Maximum number of results returned by the suggestion client
    78     private static final int SUGGESTION_MAX = 3;
    80     // The maximum number of rows deep in a search we'll dig
    81     // for an autocomplete result
    82     private static final int MAX_AUTOCOMPLETE_SEARCH = 20;
    84     // Length of https:// + 1 required to make autocomplete
    85     // fill in the domain, for both http:// and https://
    86     private static final int HTTPS_PREFIX_LENGTH = 9;
    88     // Duration for fade-in animation
    89     private static final int ANIMATION_DURATION = 250;
    91     // Holds the current search term to use in the query
    92     private volatile String mSearchTerm;
    94     // Adapter for the list of search results
    95     private SearchAdapter mAdapter;
    97     // The view shown by the fragment
    98     private LinearLayout mView;
   100     // The list showing search results
   101     private HomeListView mList;
   103     // Client that performs search suggestion queries
   104     private volatile SuggestClient mSuggestClient;
   106     // List of search engines from gecko
   107     private ArrayList<SearchEngine> mSearchEngines;
   109     // Whether search suggestions are enabled or not
   110     private boolean mSuggestionsEnabled;
   112     // Callbacks used for the search loader
   113     private CursorLoaderCallbacks mCursorLoaderCallbacks;
   115     // Callbacks used for the search suggestion loader
   116     private SuggestionLoaderCallbacks mSuggestionLoaderCallbacks;
   118     // Autocomplete handler used when filtering results
   119     private AutocompleteHandler mAutocompleteHandler;
   121     // On URL open listener
   122     private OnUrlOpenListener mUrlOpenListener;
   124     // On search listener
   125     private OnSearchListener mSearchListener;
   127     // On edit suggestion listener
   128     private OnEditSuggestionListener mEditSuggestionListener;
   130     // Whether the suggestions will fade in when shown
   131     private boolean mAnimateSuggestions;
   133     // Opt-in prompt view for search suggestions
   134     private View mSuggestionsOptInPrompt;
   136     public interface OnSearchListener {
   137         public void onSearch(SearchEngine engine, String text);
   138     }
   140     public interface OnEditSuggestionListener {
   141         public void onEditSuggestion(String suggestion);
   142     }
   144     public static BrowserSearch newInstance() {
   145         BrowserSearch browserSearch = new BrowserSearch();
   147         final Bundle args = new Bundle();
   148         args.putBoolean(HomePager.CAN_LOAD_ARG, true);
   149         browserSearch.setArguments(args);
   151         return browserSearch;
   152     }
   154     public BrowserSearch() {
   155         mSearchTerm = "";
   156     }
   158     @Override
   159     public void onAttach(Activity activity) {
   160         super.onAttach(activity);
   162         try {
   163             mUrlOpenListener = (OnUrlOpenListener) activity;
   164         } catch (ClassCastException e) {
   165             throw new ClassCastException(activity.toString()
   166                     + " must implement BrowserSearch.OnUrlOpenListener");
   167         }
   169         try {
   170             mSearchListener = (OnSearchListener) activity;
   171         } catch (ClassCastException e) {
   172             throw new ClassCastException(activity.toString()
   173                     + " must implement BrowserSearch.OnSearchListener");
   174         }
   176         try {
   177             mEditSuggestionListener = (OnEditSuggestionListener) activity;
   178         } catch (ClassCastException e) {
   179             throw new ClassCastException(activity.toString()
   180                     + " must implement BrowserSearch.OnEditSuggestionListener");
   181         }
   182     }
   184     @Override
   185     public void onDetach() {
   186         super.onDetach();
   188         mAutocompleteHandler = null;
   189         mUrlOpenListener = null;
   190         mSearchListener = null;
   191         mEditSuggestionListener = null;
   192     }
   194     @Override
   195     public void onCreate(Bundle savedInstanceState) {
   196         super.onCreate(savedInstanceState);
   198         mSearchEngines = new ArrayList<SearchEngine>();
   199     }
   201     @Override
   202     public void onDestroy() {
   203         super.onDestroy();
   205         mSearchEngines = null;
   206     }
   208     @Override
   209     public void onStart() {
   210         super.onStart();
   212         // Adjusting the window size when showing the keyboard results in the underlying
   213         // activity being painted when the keyboard is hidden (bug 933422). This can be
   214         // prevented by not resizing the window.
   215         getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
   216     }
   218     @Override
   219     public void onStop() {
   220         super.onStop();
   222         getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
   223     }
   225     @Override
   226     public void onResume() {
   227         super.onResume();
   229         Telemetry.startUISession(TelemetryContract.Session.FRECENCY);
   230     }
   232     @Override
   233     public void onPause() {
   234         super.onPause();
   236         Telemetry.stopUISession(TelemetryContract.Session.FRECENCY);
   237     }
   239     @Override
   240     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
   241         // All list views are styled to look the same with a global activity theme.
   242         // If the style of the list changes, inflate it from an XML.
   243         mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
   244         mList = (HomeListView) mView.findViewById(R.id.home_list_view);
   246         return mView;
   247     }
   249     @Override
   250     public void onDestroyView() {
   251         super.onDestroyView();
   253         unregisterEventListener("SearchEngines:Data");
   255         mList.setAdapter(null);
   256         mList = null;
   258         mView = null;
   259         mSuggestionsOptInPrompt = null;
   260         mSuggestClient = null;
   261     }
   263     @Override
   264     public void onViewCreated(View view, Bundle savedInstanceState) {
   265         super.onViewCreated(view, savedInstanceState);
   266         mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH);
   268         mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
   269             @Override
   270             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
   271                 // Perform the user-entered search if the user clicks on a search engine row.
   272                 // This row will be disabled if suggestions (in addition to the user-entered term) are showing.
   273                 if (view instanceof SearchEngineRow) {
   274                     ((SearchEngineRow) view).performUserEnteredSearch();
   275                     return;
   276                 }
   278                 // Account for the search engine rows.
   279                 position -= getSuggestEngineCount();
   280                 final Cursor c = mAdapter.getCursor(position);
   281                 final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
   283                 // The "urlbar" and "frecency" sessions can be open at the same time. Use the LIST_ITEM
   284                 // method to set this LOAD_URL event apart from the case where the user commits what's in
   285                 // the url bar.
   286                 Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM);
   288                 // This item is a TwoLinePageRow, so we allow switch-to-tab.
   289                 mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
   290             }
   291         });
   293         mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
   294             @Override
   295             public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
   296                 // Don't do anything when the user long-clicks on a search engine row.
   297                 if (view instanceof SearchEngineRow) {
   298                     return true;
   299                 }
   301                 // Account for the search engine rows.
   302                 position -= getSuggestEngineCount();
   303                 return mList.onItemLongClick(parent, view, position, id);
   304             }
   305         });
   307         final ListSelectionListener listener = new ListSelectionListener();
   308         mList.setOnItemSelectedListener(listener);
   309         mList.setOnFocusChangeListener(listener);
   311         mList.setOnKeyListener(new View.OnKeyListener() {
   312             @Override
   313             public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
   314                 final View selected = mList.getSelectedView();
   316                 if (selected instanceof SearchEngineRow) {
   317                     return selected.onKeyDown(keyCode, event);
   318                 }
   319                 return false;
   320             }
   321         });
   323         registerForContextMenu(mList);
   324         registerEventListener("SearchEngines:Data");
   326         GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:GetVisible", null));
   327     }
   329     @Override
   330     public void onActivityCreated(Bundle savedInstanceState) {
   331         super.onActivityCreated(savedInstanceState);
   333         // Intialize the search adapter
   334         mAdapter = new SearchAdapter(getActivity());
   335         mList.setAdapter(mAdapter);
   337         // Only create an instance when we need it
   338         mSuggestionLoaderCallbacks = null;
   340         // Create callbacks before the initial loader is started
   341         mCursorLoaderCallbacks = new CursorLoaderCallbacks();
   342         loadIfVisible();
   343     }
   345     @Override
   346     public void handleMessage(String event, final JSONObject message) {
   347         if (event.equals("SearchEngines:Data")) {
   348             ThreadUtils.postToUiThread(new Runnable() {
   349                 @Override
   350                 public void run() {
   351                     setSearchEngines(message);
   352                 }
   353             });
   354         }
   355     }
   357     @Override
   358     protected void load() {
   359         SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
   360     }
   362     private void handleAutocomplete(String searchTerm, Cursor c) {
   363         if (c == null ||
   364             mAutocompleteHandler == null ||
   365             TextUtils.isEmpty(searchTerm)) {
   366             return;
   367         }
   369         // Avoid searching the path if we don't have to. Currently just
   370         // decided by whether there is a '/' character in the string.
   371         final boolean searchPath = searchTerm.indexOf('/') > 0;
   372         final String autocompletion = findAutocompletion(searchTerm, c, searchPath);
   374         if (autocompletion == null || mAutocompleteHandler == null) {
   375             return;
   376         }
   378         // Prefetch auto-completed domain since it's a likely target
   379         GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", "http://" + autocompletion));
   381         mAutocompleteHandler.onAutocomplete(autocompletion);
   382         mAutocompleteHandler = null;
   383     }
   385     /**
   386      * Returns the substring of a provided URI, starting at the given offset,
   387      * and extending up to the end of the path segment in which the provided
   388      * index is found.
   389      *
   390      * For example, given
   391      *
   392      *   "www.reddit.com/r/boop/abcdef", 0, ?
   393      *
   394      * this method returns
   395      *
   396      *   ?=2:  "www.reddit.com/"
   397      *   ?=17: "www.reddit.com/r/boop/"
   398      *   ?=21: "www.reddit.com/r/boop/"
   399      *   ?=22: "www.reddit.com/r/boop/abcdef"
   400      *
   401      */
   402     private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) {
   403         final int afterEnd = url.length();
   405         // We want to include the trailing slash, but not other characters.
   406         int chop = url.indexOf('/', begin);
   407         if (chop != -1) {
   408             ++chop;
   409             if (chop < offset) {
   410                 // This isn't supposed to happen. Fall back to returning the whole damn thing.
   411                 return url;
   412             }
   413         } else {
   414             chop = url.indexOf('?', begin);
   415             if (chop == -1) {
   416                 chop = url.indexOf('#', begin);
   417             }
   418             if (chop == -1) {
   419                 chop = afterEnd;
   420             }
   421         }
   423         return url.substring(offset, chop);
   424     }
   426     private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) {
   427         if (!c.moveToFirst()) {
   428             return null;
   429         }
   431         final int searchLength = searchTerm.length();
   432         final int urlIndex = c.getColumnIndexOrThrow(URLColumns.URL);
   433         int searchCount = 0;
   435         do {
   436             final String url = c.getString(urlIndex);
   438             if (searchCount == 0) {
   439                 // Prefetch the first item in the list since it's weighted the highest
   440                 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", url.toString()));
   441             }
   443             // Does the completion match against the whole URL? This will match
   444             // about: pages, as well as user input including "http://...".
   445             if (url.startsWith(searchTerm)) {
   446                 return uriSubstringUpToMatchedPath(url, 0,
   447                         (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH);
   448             }
   450             final Uri uri = Uri.parse(url);
   451             final String host = uri.getHost();
   453             // Host may be null for about pages.
   454             if (host == null) {
   455                 continue;
   456             }
   458             if (host.startsWith(searchTerm)) {
   459                 return host + "/";
   460             }
   462             final String strippedHost = StringUtils.stripCommonSubdomains(host);
   463             if (strippedHost.startsWith(searchTerm)) {
   464                 return strippedHost + "/";
   465             }
   467             ++searchCount;
   469             if (!searchPath) {
   470                 continue;
   471             }
   473             // Otherwise, if we're matching paths, let's compare against the string itself.
   474             final int hostOffset = url.indexOf(strippedHost);
   475             if (hostOffset == -1) {
   476                 // This was a URL string that parsed to a different host (normalized?).
   477                 // Give up.
   478                 continue;
   479             }
   481             // We already matched the non-stripped host, so now we're
   482             // substring-searching in the part of the URL without the common
   483             // subdomains.
   484             if (url.startsWith(searchTerm, hostOffset)) {
   485                 // Great! Return including the rest of the path segment.
   486                 return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength);
   487             }
   488         } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext());
   490         return null;
   491     }
   493     private void filterSuggestions() {
   494         if (mSuggestClient == null || !mSuggestionsEnabled) {
   495             return;
   496         }
   498         if (mSuggestionLoaderCallbacks == null) {
   499             mSuggestionLoaderCallbacks = new SuggestionLoaderCallbacks();
   500         }
   502         getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSuggestionLoaderCallbacks);
   503     }
   505     private void setSuggestions(ArrayList<String> suggestions) {
   506         mSearchEngines.get(0).setSuggestions(suggestions);
   507         mAdapter.notifyDataSetChanged();
   508     }
   510     private void setSearchEngines(JSONObject data) {
   511         // This method is called via a Runnable posted from the Gecko thread, so
   512         // it's possible the fragment and/or its view has been destroyed by the
   513         // time we get here. If so, just abort.
   514         if (mView == null) {
   515             return;
   516         }
   518         try {
   519             final JSONObject suggest = data.getJSONObject("suggest");
   520             final String suggestEngine = suggest.optString("engine", null);
   521             final String suggestTemplate = suggest.optString("template", null);
   522             final boolean suggestionsPrompted = suggest.getBoolean("prompted");
   523             final JSONArray engines = data.getJSONArray("searchEngines");
   525             mSuggestionsEnabled = suggest.getBoolean("enabled");
   527             ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>();
   528             for (int i = 0; i < engines.length(); i++) {
   529                 final JSONObject engineJSON = engines.getJSONObject(i);
   530                 final SearchEngine engine = new SearchEngine(engineJSON);
   532                 if (engine.name.equals(suggestEngine) && suggestTemplate != null) {
   533                     // Suggest engine should be at the front of the list.
   534                     searchEngines.add(0, engine);
   536                     // The only time Tabs.getInstance().getSelectedTab() should
   537                     // be null is when we're restoring after a crash. We should
   538                     // never restore private tabs when that happens, so it
   539                     // should be safe to assume that null means non-private.
   540                     Tab tab = Tabs.getInstance().getSelectedTab();
   541                     final boolean isPrivate = (tab != null && tab.isPrivate());
   543                     // Only create a new instance of SuggestClient if it hasn't been
   544                     // set yet. e.g. Robocop tests might set it directly before search
   545                     // engines are loaded.
   546                     if (mSuggestClient == null && !isPrivate) {
   547                         setSuggestClient(new SuggestClient(getActivity(), suggestTemplate,
   548                                     SUGGESTION_TIMEOUT, SUGGESTION_MAX));
   549                     }
   550                 } else {
   551                     searchEngines.add(engine);
   552                 }
   553             }
   555             mSearchEngines = searchEngines;
   557             if (mAdapter != null) {
   558                 mAdapter.notifyDataSetChanged();
   559             }
   561             // Show suggestions opt-in prompt only if suggestions are not enabled yet,
   562             // user hasn't been prompted and we're not on a private browsing tab.
   563             if (!mSuggestionsEnabled && !suggestionsPrompted && mSuggestClient != null) {
   564                 showSuggestionsOptIn();
   565             }
   566         } catch (JSONException e) {
   567             Log.e(LOGTAG, "Error getting search engine JSON", e);
   568         }
   570         filterSuggestions();
   571     }
   573     /**
   574      * Sets the private SuggestClient instance. Should only be called if the suggestClient is
   575      * null (i.e. has not yet been initialized or has been nulled). Non-private access is
   576      * for testing purposes only.
   577      */
   578     @RobocopTarget
   579     public void setSuggestClient(final SuggestClient client) {
   580         if (mSuggestClient != null) {
   581             throw new IllegalStateException("Can only set the SuggestClient if it has not " +
   582                     "yet been initialized!");
   583         }
   584         mSuggestClient = client;
   585     }
   587     private void showSuggestionsOptIn() {
   588         // Return if the ViewStub was already inflated - an inflated ViewStub is removed from the
   589         // View hierarchy so a second call to findViewById will return null.
   590         if (mSuggestionsOptInPrompt != null) {
   591             return;
   592         }
   594         mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate();
   596         TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title);
   597         promptText.setText(getResources().getString(R.string.suggestions_prompt));
   599         final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes);
   600         final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no);
   602         final OnClickListener listener = new OnClickListener() {
   603             @Override
   604             public void onClick(View v) {
   605                 // Prevent the buttons from being clicked multiple times (bug 816902)
   606                 yesButton.setOnClickListener(null);
   607                 noButton.setOnClickListener(null);
   609                 setSuggestionsEnabled(v == yesButton);
   610             }
   611         };
   613         yesButton.setOnClickListener(listener);
   614         noButton.setOnClickListener(listener);
   616         // If the prompt gains focus, automatically pass focus to the
   617         // yes button in the prompt.
   618         final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
   619         prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() {
   620             @Override
   621             public void onFocusChange(View v, boolean hasFocus) {
   622                 if (hasFocus) {
   623                     yesButton.requestFocus();
   624                 }
   625             }
   626         });
   627     }
   629     private void setSuggestionsEnabled(final boolean enabled) {
   630         // Clicking the yes/no buttons quickly can cause the click events be
   631         // queued before the listeners are removed above, so it's possible
   632         // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt
   633         // can be null if this happens (bug 828480).
   634         if (mSuggestionsOptInPrompt == null) {
   635             return;
   636         }
   638         // Make suggestions appear immediately after the user opts in
   639         ThreadUtils.postToBackgroundThread(new Runnable() {
   640             @Override
   641             public void run() {
   642                 SuggestClient client = mSuggestClient;
   643                 if (client != null) {
   644                     client.query(mSearchTerm);
   645                 }
   646             }
   647         });
   649         // Pref observer in gecko will also set prompted = true
   650         PrefsHelper.setPref("browser.search.suggest.enabled", enabled);
   652         TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0);
   653         slideAnimation.setDuration(ANIMATION_DURATION);
   654         slideAnimation.setInterpolator(new AccelerateInterpolator());
   655         slideAnimation.setFillAfter(true);
   656         final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
   658         TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight());
   659         shrinkAnimation.setDuration(ANIMATION_DURATION);
   660         shrinkAnimation.setFillAfter(true);
   661         shrinkAnimation.setStartOffset(slideAnimation.getDuration());
   662         shrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
   663             @Override
   664             public void onAnimationStart(Animation a) {
   665                 // Increase the height of the view so a gap isn't shown during animation
   666                 mView.getLayoutParams().height = mView.getHeight() +
   667                         mSuggestionsOptInPrompt.getHeight();
   668                 mView.requestLayout();
   669             }
   671             @Override
   672             public void onAnimationRepeat(Animation a) {}
   674             @Override
   675             public void onAnimationEnd(Animation a) {
   676                 // Removing the view immediately results in a NPE in
   677                 // dispatchDraw(), possibly because this callback executes
   678                 // before drawing is finished. Posting this as a Runnable fixes
   679                 // the issue.
   680                 mView.post(new Runnable() {
   681                     @Override
   682                     public void run() {
   683                         mView.removeView(mSuggestionsOptInPrompt);
   684                         mList.clearAnimation();
   685                         mSuggestionsOptInPrompt = null;
   687                         if (enabled) {
   688                             // Reset the view height
   689                             mView.getLayoutParams().height = LayoutParams.MATCH_PARENT;
   691                             mSuggestionsEnabled = enabled;
   692                             mAnimateSuggestions = true;
   693                             mAdapter.notifyDataSetChanged();
   694                             filterSuggestions();
   695                         }
   696                     }
   697                 });
   698             }
   699         });
   701         prompt.startAnimation(slideAnimation);
   702         mSuggestionsOptInPrompt.startAnimation(shrinkAnimation);
   703         mList.startAnimation(shrinkAnimation);
   704     }
   706     private int getSuggestEngineCount() {
   707         return (TextUtils.isEmpty(mSearchTerm) || mSuggestClient == null || !mSuggestionsEnabled) ? 0 : 1;
   708     }
   710     private void registerEventListener(String eventName) {
   711         GeckoAppShell.registerEventListener(eventName, this);
   712     }
   714     private void unregisterEventListener(String eventName) {
   715         GeckoAppShell.unregisterEventListener(eventName, this);
   716     }
   718     private void restartSearchLoader() {
   719         SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
   720     }
   722     private void initSearchLoader() {
   723         SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
   724     }
   726     public void filter(String searchTerm, AutocompleteHandler handler) {
   727         if (TextUtils.isEmpty(searchTerm)) {
   728             return;
   729         }
   731         final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm);
   733         mSearchTerm = searchTerm;
   734         mAutocompleteHandler = handler;
   736         if (isVisible()) {
   737             if (isNewFilter) {
   738                 // The adapter depends on the search term to determine its number
   739                 // of items. Make it we notify the view about it.
   740                 mAdapter.notifyDataSetChanged();
   742                 // Restart loaders with the new search term
   743                 restartSearchLoader();
   744                 filterSuggestions();
   745             } else {
   746                 // The search term hasn't changed, simply reuse any existing
   747                 // loader for the current search term. This will ensure autocompletion
   748                 // is consistently triggered (see bug 933739).
   749                 initSearchLoader();
   750             }
   751         }
   752     }
   754     private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> {
   755         private final SuggestClient mSuggestClient;
   756         private final String mSearchTerm;
   757         private ArrayList<String> mSuggestions;
   759         public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
   760             super(context);
   761             mSuggestClient = suggestClient;
   762             mSearchTerm = searchTerm;
   763             mSuggestions = null;
   764         }
   766         @Override
   767         public ArrayList<String> loadInBackground() {
   768             return mSuggestClient.query(mSearchTerm);
   769         }
   771         @Override
   772         public void deliverResult(ArrayList<String> suggestions) {
   773             mSuggestions = suggestions;
   775             if (isStarted()) {
   776                 super.deliverResult(mSuggestions);
   777             }
   778         }
   780         @Override
   781         protected void onStartLoading() {
   782             if (mSuggestions != null) {
   783                 deliverResult(mSuggestions);
   784             }
   786             if (takeContentChanged() || mSuggestions == null) {
   787                 forceLoad();
   788             }
   789         }
   791         @Override
   792         protected void onStopLoading() {
   793             cancelLoad();
   794         }
   796         @Override
   797         protected void onReset() {
   798             super.onReset();
   800             onStopLoading();
   801             mSuggestions = null;
   802         }
   803     }
   805     private class SearchAdapter extends MultiTypeCursorAdapter {
   806         private static final int ROW_SEARCH = 0;
   807         private static final int ROW_STANDARD = 1;
   808         private static final int ROW_SUGGEST = 2;
   810         public SearchAdapter(Context context) {
   811             super(context, null, new int[] { ROW_STANDARD,
   812                                              ROW_SEARCH,
   813                                              ROW_SUGGEST },
   814                                  new int[] { R.layout.home_item_row,
   815                                              R.layout.home_search_item_row,
   816                                              R.layout.home_search_item_row });
   817         }
   819         @Override
   820         public int getItemViewType(int position) {
   821             final int engine = getEngineIndex(position);
   823             if (engine == -1) {
   824                 return ROW_STANDARD;
   825             } else if (engine == 0 && mSuggestionsEnabled) {
   826                 // Give suggestion views their own type to prevent them from
   827                 // sharing other recycled search engine views. Using other
   828                 // recycled views for the suggestion row can break animations
   829                 // (bug 815937).
   830                 return ROW_SUGGEST;
   831             }
   833             return ROW_SEARCH;
   834         }
   836         @Override
   837         public boolean isEnabled(int position) {
   838             // If we're using a gamepad or keyboard, allow the row to be
   839             // focused so it can pass the focus to its child suggestion views.
   840             if (!mList.isInTouchMode()) {
   841                 return true;
   842             }
   844             // If the suggestion row only contains one item (the user-entered
   845             // query), allow the entire row to be clickable; clicking the row
   846             // has the same effect as clicking the single suggestion. If the
   847             // row contains multiple items, clicking the row will do nothing.
   848             final int index = getEngineIndex(position);
   849             if (index != -1) {
   850                 return !mSearchEngines.get(index).hasSuggestions();
   851             }
   853             return true;
   854         }
   856         // Add the search engines to the number of reported results.
   857         @Override
   858         public int getCount() {
   859             final int resultCount = super.getCount();
   861             // Don't show search engines or suggestions if search field is empty
   862             if (TextUtils.isEmpty(mSearchTerm)) {
   863                 return resultCount;
   864             }
   866             return resultCount + mSearchEngines.size();
   867         }
   869         @Override
   870         public void bindView(View view, Context context, int position) {
   871             final int type = getItemViewType(position);
   873             if (type == ROW_SEARCH || type == ROW_SUGGEST) {
   874                 final SearchEngineRow row = (SearchEngineRow) view;
   875                 row.setOnUrlOpenListener(mUrlOpenListener);
   876                 row.setOnSearchListener(mSearchListener);
   877                 row.setOnEditSuggestionListener(mEditSuggestionListener);
   878                 row.setSearchTerm(mSearchTerm);
   880                 final SearchEngine engine = mSearchEngines.get(getEngineIndex(position));
   881                 final boolean animate = (mAnimateSuggestions && engine.hasSuggestions());
   882                 row.updateFromSearchEngine(engine, animate);
   883                 if (animate) {
   884                     // Only animate suggestions the first time they are shown
   885                     mAnimateSuggestions = false;
   886                 }
   887             } else {
   888                 // Account for the search engines
   889                 position -= getSuggestEngineCount();
   891                 final Cursor c = getCursor(position);
   892                 final TwoLinePageRow row = (TwoLinePageRow) view;
   893                 row.updateFromCursor(c);
   894             }
   895         }
   897         private int getEngineIndex(int position) {
   898             final int resultCount = super.getCount();
   899             final int suggestEngineCount = getSuggestEngineCount();
   901             // Return suggest engine index
   902             if (position < suggestEngineCount) {
   903                 return position;
   904             }
   906             // Not an engine
   907             if (position - suggestEngineCount < resultCount) {
   908                 return -1;
   909             }
   911             // Return search engine index
   912             return position - resultCount;
   913         }
   914     }
   916     private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
   917         @Override
   918         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
   919             return SearchLoader.createInstance(getActivity(), args);
   920         }
   922         @Override
   923         public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
   924             mAdapter.swapCursor(c);
   926             // We should handle autocompletion based on the search term
   927             // associated with the loader that has just provided
   928             // the results.
   929             SearchCursorLoader searchLoader = (SearchCursorLoader) loader;
   930             handleAutocomplete(searchLoader.getSearchTerm(), c);
   931         }
   933         @Override
   934         public void onLoaderReset(Loader<Cursor> loader) {
   935             mAdapter.swapCursor(null);
   936         }
   937     }
   939     private class SuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
   940         @Override
   941         public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
   942             // mSuggestClient is set to null in onDestroyView(), so using it
   943             // safely here relies on the fact that onCreateLoader() is called
   944             // synchronously in restartLoader().
   945             return new SuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm);
   946         }
   948         @Override
   949         public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
   950             setSuggestions(suggestions);
   951         }
   953         @Override
   954         public void onLoaderReset(Loader<ArrayList<String>> loader) {
   955             setSuggestions(new ArrayList<String>());
   956         }
   957     }
   959     private static class ListSelectionListener implements View.OnFocusChangeListener,
   960                                                           AdapterView.OnItemSelectedListener {
   961         private SearchEngineRow mSelectedEngineRow;
   963         @Override
   964         public void onFocusChange(View v, boolean hasFocus) {
   965             if (hasFocus) {
   966                 View selectedRow = ((ListView) v).getSelectedView();
   967                 if (selectedRow != null) {
   968                     selectRow(selectedRow);
   969                 }
   970             } else {
   971                 deselectRow();
   972             }
   973         }
   975         @Override
   976         public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
   977             deselectRow();
   978             selectRow(view);
   979         }
   981         @Override
   982         public void onNothingSelected(AdapterView<?> parent) {
   983             deselectRow();
   984         }
   986         private void selectRow(View row) {
   987             if (row instanceof SearchEngineRow) {
   988                 mSelectedEngineRow = (SearchEngineRow) row;
   989                 mSelectedEngineRow.onSelected();
   990             }
   991         }
   993         private void deselectRow() {
   994             if (mSelectedEngineRow != null) {
   995                 mSelectedEngineRow.onDeselected();
   996                 mSelectedEngineRow = null;
   997             }
   998         }
   999     }
  1001     /**
  1002      * HomeSearchListView is a list view for displaying search engine results on the awesome screen.
  1003      */
  1004     public static class HomeSearchListView extends HomeListView {
  1005         public HomeSearchListView(Context context, AttributeSet attrs) {
  1006             this(context, attrs, R.attr.homeListViewStyle);
  1009         public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) {
  1010             super(context, attrs, defStyle);
  1013         @Override
  1014         public boolean onTouchEvent(MotionEvent event) {
  1015             if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
  1016                 // Dismiss the soft keyboard.
  1017                 requestFocus();
  1020             return super.onTouchEvent(event);

mercurial