summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java1316
1 files changed, 1316 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
new file mode 100644
index 000000000..7732932fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
@@ -0,0 +1,1316 @@
+/* -*- 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.home;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+
+import android.content.SharedPreferences;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SuggestClient;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.toolbar.AutocompleteHandler;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.database.Cursor;
+import android.net.Uri;
+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.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+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;
+
+/**
+ * Fragment that displays frecency search results in a ListView.
+ */
+public class BrowserSearch extends HomeFragment
+ implements GeckoEventListener,
+ SearchEngineBar.OnSearchBarClickListener {
+
+ @RobocopTarget
+ public interface SuggestClientFactory {
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max);
+ }
+
+ @RobocopTarget
+ public static class DefaultSuggestClientFactory implements SuggestClientFactory {
+ @Override
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max) {
+ return new SuggestClient(context, template, timeout, max, true);
+ }
+ }
+
+ /**
+ * Set this to mock the suggestion mechanism. Public for access from tests.
+ */
+ @RobocopTarget
+ public static volatile SuggestClientFactory sSuggestClientFactory = new DefaultSuggestClientFactory();
+
+ // Logging tag name
+ private static final String LOGTAG = "GeckoBrowserSearch";
+
+ // Cursor loader ID for search query
+ private static final int LOADER_ID_SEARCH = 0;
+
+ // AsyncTask loader ID for suggestion query
+ private static final int LOADER_ID_SUGGESTION = 1;
+ private static final int LOADER_ID_SAVED_SUGGESTION = 2;
+
+ // Timeout for the suggestion client to respond
+ private static final int SUGGESTION_TIMEOUT = 3000;
+
+ // Maximum number of suggestions from the search engine's suggestion client. This impacts network traffic and device
+ // data consumption whereas R.integer.max_saved_suggestions controls how many suggestion to show in the UI.
+ private static final int NETWORK_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;
+
+ // Length of https:// + 1 required to make autocomplete
+ // fill in the domain, for both http:// and https://
+ private static final int HTTPS_PREFIX_LENGTH = 9;
+
+ // Duration for fade-in animation
+ private static final int ANIMATION_DURATION = 250;
+
+ // Holds the current search term to use in the query
+ private volatile String mSearchTerm;
+
+ // Adapter for the list of search results
+ private SearchAdapter mAdapter;
+
+ // The view shown by the fragment
+ private LinearLayout mView;
+
+ // The list showing search results
+ private HomeListView mList;
+
+ // The bar on the bottom of the screen displaying search engine options.
+ private SearchEngineBar mSearchEngineBar;
+
+ // Client that performs search suggestion queries.
+ // Public for testing.
+ @RobocopTarget
+ public volatile SuggestClient mSuggestClient;
+
+ // List of search engines from Gecko.
+ // Do not mutate this list.
+ // Access to this member must only occur from the UI thread.
+ private List<SearchEngine> mSearchEngines;
+
+ // Search history suggestions
+ private ArrayList<String> mSearchHistorySuggestions;
+
+ // Track the locale that was last in use when we filled mSearchEngines.
+ // Access to this member must only occur from the UI thread.
+ private Locale mLastLocale;
+
+ // Whether search suggestions are enabled or not
+ private boolean mSuggestionsEnabled;
+
+ // Whether history suggestions are enabled or not
+ private boolean mSavedSearchesEnabled;
+
+ // Callbacks used for the search loader
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ // Callbacks used for the search suggestion loader
+ private SearchEngineSuggestionLoaderCallbacks mSearchEngineSuggestionLoaderCallbacks;
+ private SearchHistorySuggestionLoaderCallbacks mSearchHistorySuggestionLoaderCallback;
+
+ // Autocomplete handler used when filtering results
+ private AutocompleteHandler mAutocompleteHandler;
+
+ // 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 {
+ void onSearch(SearchEngine engine, String text, TelemetryContract.Method method);
+ }
+
+ public interface OnEditSuggestionListener {
+ public void onEditSuggestion(String suggestion);
+ }
+
+ public static BrowserSearch newInstance() {
+ BrowserSearch browserSearch = new BrowserSearch();
+
+ final Bundle args = new Bundle();
+ args.putBoolean(HomePager.CAN_LOAD_ARG, true);
+ browserSearch.setArguments(args);
+
+ return browserSearch;
+ }
+
+ public BrowserSearch() {
+ mSearchTerm = "";
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ 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");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+
+ mAutocompleteHandler = null;
+ mSearchListener = null;
+ mEditSuggestionListener = null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mSearchEngines = new ArrayList<SearchEngine>();
+ mSearchHistorySuggestions = new ArrayList<>();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ mSearchEngines = null;
+ }
+
+ @Override
+ public void onHiddenChanged(boolean hidden) {
+ if (!hidden) {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Removes Search Suggestions Loader if in private browsing mode
+ // Loader may have been inserted when browsing in normal tab
+ if (isPrivate) {
+ getLoaderManager().destroyLoader(LOADER_ID_SUGGESTION);
+ }
+
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ }
+ super.onHiddenChanged(hidden);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ mSavedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true);
+
+ // Fetch engines if we need to.
+ if (mSearchEngines.isEmpty() || !Locale.getDefault().equals(mLastLocale)) {
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ } else {
+ updateSearchEngineBar();
+ }
+
+ Telemetry.startUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ Telemetry.stopUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @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.
+ mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
+ mList = (HomeListView) mView.findViewById(R.id.home_list_view);
+ mSearchEngineBar = (SearchEngineBar) mView.findViewById(R.id.search_engine_bar);
+
+ return mView;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setAdapter(null);
+ mSearchEngineBar = null;
+
+ mList.setAdapter(null);
+ mList = null;
+
+ mView = null;
+ mSuggestionsOptInPrompt = null;
+ mSuggestClient = null;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH);
+
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // Perform the user-entered search if the user clicks on a search engine row.
+ // This row will be disabled if suggestions (in addition to the user-entered term) are showing.
+ if (view instanceof SearchEngineRow) {
+ ((SearchEngineRow) view).performUserEnteredSearch();
+ return;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ final Cursor c = mAdapter.getCursor(position);
+ final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "frecency");
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ });
+
+ mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Don't do anything when the user long-clicks on a search engine row.
+ if (view instanceof SearchEngineRow) {
+ return true;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ return mList.onItemLongClick(parent, view, position, id);
+ }
+ });
+
+ final ListSelectionListener listener = new ListSelectionListener();
+ mList.setOnItemSelectedListener(listener);
+ mList.setOnFocusChangeListener(listener);
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+
+ int bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+ info.bookmarkId = bookmarkId;
+
+ int historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ info.historyId = historyId;
+
+ boolean isBookmark = bookmarkId != -1;
+ boolean isHistory = historyId != -1;
+
+ if (isBookmark && isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.COMBINED;
+ } else if (isBookmark) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.BOOKMARKS;
+ } else if (isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
+ }
+
+ return info;
+ }
+ });
+
+ mList.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
+ final View selected = mList.getSelectedView();
+
+ if (selected instanceof SearchEngineRow) {
+ return selected.onKeyDown(keyCode, event);
+ }
+ return false;
+ }
+ });
+
+ registerForContextMenu(mList);
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setOnSearchBarClickListener(this);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return;
+ }
+
+ HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+
+ MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.browsersearch_contextmenu, menu);
+
+ menu.setHeaderTitle(info.getDisplayTitle());
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return false;
+ }
+
+ final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+ final Context context = getActivity();
+
+ final int itemId = item.getItemId();
+
+ if (itemId == R.id.browsersearch_remove) {
+ // Position for Top Sites grid items, but will always be -1 since this is only for BrowserSearch result
+ final int position = -1;
+
+ new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ // Initialize the search adapter
+ mAdapter = new SearchAdapter(getActivity());
+ mList.setAdapter(mAdapter);
+
+ // Only create an instance when we need it
+ mSearchEngineSuggestionLoaderCallbacks = null;
+ mSearchHistorySuggestionLoaderCallback = null;
+
+ // Create callbacks before the initial loader is started
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void handleMessage(String event, final JSONObject message) {
+ if (event.equals("SearchEngines:Data")) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setSearchEngines(message);
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void load() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void handleAutocomplete(String searchTerm, Cursor c) {
+ if (c == null ||
+ mAutocompleteHandler == null ||
+ TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ // Avoid searching the path if we don't have to. Currently just
+ // decided by whether there is a '/' character in the string.
+ final boolean searchPath = searchTerm.indexOf('/') > 0;
+ final String autocompletion = findAutocompletion(searchTerm, c, searchPath);
+
+ if (autocompletion == null || mAutocompleteHandler == null) {
+ return;
+ }
+
+ // Prefetch auto-completed domain since it's a likely target
+ GeckoAppShell.notifyObservers("Session:Prefetch", "http://" + autocompletion);
+
+ mAutocompleteHandler.onAutocomplete(autocompletion);
+ mAutocompleteHandler = null;
+ }
+
+ /**
+ * Returns the substring of a provided URI, starting at the given offset,
+ * and extending up to the end of the path segment in which the provided
+ * index is found.
+ *
+ * For example, given
+ *
+ * "www.reddit.com/r/boop/abcdef", 0, ?
+ *
+ * this method returns
+ *
+ * ?=2: "www.reddit.com/"
+ * ?=17: "www.reddit.com/r/boop/"
+ * ?=21: "www.reddit.com/r/boop/"
+ * ?=22: "www.reddit.com/r/boop/abcdef"
+ *
+ */
+ private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) {
+ final int afterEnd = url.length();
+
+ // We want to include the trailing slash, but not other characters.
+ int chop = url.indexOf('/', begin);
+ if (chop != -1) {
+ ++chop;
+ if (chop < offset) {
+ // This isn't supposed to happen. Fall back to returning the whole damn thing.
+ return url;
+ }
+ } else {
+ chop = url.indexOf('?', begin);
+ if (chop == -1) {
+ chop = url.indexOf('#', begin);
+ }
+ if (chop == -1) {
+ chop = afterEnd;
+ }
+ }
+
+ return url.substring(offset, chop);
+ }
+
+ LinkedHashSet<String> domains = null;
+ private LinkedHashSet<String> getDomains() {
+ if (domains == null) {
+ domains = new LinkedHashSet<String>(500);
+ BufferedReader buf = null;
+ try {
+ buf = new BufferedReader(new InputStreamReader(getResources().openRawResource(R.raw.topdomains)));
+ String res = null;
+
+ do {
+ res = buf.readLine();
+ if (res != null) {
+ domains.add(res);
+ }
+ } while (res != null);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error reading domains", e);
+ } finally {
+ if (buf != null) {
+ try {
+ buf.close();
+ } catch (IOException e) { }
+ }
+ }
+ }
+ return domains;
+ }
+
+ private String searchDomains(String search) {
+ for (String domain : getDomains()) {
+ if (domain.startsWith(search)) {
+ return domain;
+ }
+ }
+ return null;
+ }
+
+ private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) {
+ if (!c.moveToFirst()) {
+ // No cursor probably means no history, so let's try the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ final int searchLength = searchTerm.length();
+ final int urlIndex = c.getColumnIndexOrThrow(History.URL);
+ int searchCount = 0;
+
+ do {
+ final String url = c.getString(urlIndex);
+
+ if (searchCount == 0) {
+ // Prefetch the first item in the list since it's weighted the highest
+ GeckoAppShell.notifyObservers("Session:Prefetch", url);
+ }
+
+ // Does the completion match against the whole URL? This will match
+ // about: pages, as well as user input including "http://...".
+ if (url.startsWith(searchTerm)) {
+ return uriSubstringUpToMatchedPath(url, 0,
+ (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH);
+ }
+
+ final Uri uri = Uri.parse(url);
+ final String host = uri.getHost();
+
+ // Host may be null for about pages.
+ if (host == null) {
+ continue;
+ }
+
+ if (host.startsWith(searchTerm)) {
+ return host + "/";
+ }
+
+ final String strippedHost = StringUtils.stripCommonSubdomains(host);
+ if (strippedHost.startsWith(searchTerm)) {
+ return strippedHost + "/";
+ }
+
+ ++searchCount;
+
+ if (!searchPath) {
+ continue;
+ }
+
+ // Otherwise, if we're matching paths, let's compare against the string itself.
+ final int hostOffset = url.indexOf(strippedHost);
+ if (hostOffset == -1) {
+ // This was a URL string that parsed to a different host (normalized?).
+ // Give up.
+ continue;
+ }
+
+ // We already matched the non-stripped host, so now we're
+ // substring-searching in the part of the URL without the common
+ // subdomains.
+ if (url.startsWith(searchTerm, hostOffset)) {
+ // Great! Return including the rest of the path segment.
+ return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength);
+ }
+ } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext());
+
+ // If we can't find an autocompletion domain from history, let's try using the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ public void resetScrollState() {
+ mSearchEngineBar.scrollToPosition(0);
+ }
+
+ private void filterSuggestions() {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // mSuggestClient may be null if we haven't received our search engine list yet - hence
+ // we need to exit here in that case.
+ if (isPrivate || mSuggestClient == null || (!mSuggestionsEnabled && !mSavedSearchesEnabled)) {
+ mSearchHistorySuggestions.clear();
+ return;
+ }
+
+ // Suggestions from search engine
+ if (mSearchEngineSuggestionLoaderCallbacks == null) {
+ mSearchEngineSuggestionLoaderCallbacks = new SearchEngineSuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSearchEngineSuggestionLoaderCallbacks);
+
+ // Saved suggestions
+ if (mSearchHistorySuggestionLoaderCallback == null) {
+ mSearchHistorySuggestionLoaderCallback = new SearchHistorySuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SAVED_SUGGESTION, null, mSearchHistorySuggestionLoaderCallback);
+ }
+
+ private void setSuggestions(ArrayList<String> suggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ // mSearchEngines may be null if the setSuggestions calls after onDestroy (bug 1310621).
+ // So drop the suggestions if search engines are not available
+ if (mSearchEngines != null && !mSearchEngines.isEmpty()) {
+ mSearchEngines.get(0).setSuggestions(suggestions);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ }
+
+ private void setSavedSuggestions(ArrayList<String> savedSuggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ mSearchHistorySuggestions = savedSuggestions;
+ mAdapter.notifyDataSetChanged();
+ }
+
+ private boolean shouldUpdateSearchEngine(ArrayList<SearchEngine> searchEngines) {
+ if (searchEngines.size() != mSearchEngines.size()) {
+ return true;
+ }
+
+ int size = searchEngines.size();
+
+ for (int i = 0; i < size; i++) {
+ if (!mSearchEngines.get(i).name.equals(searchEngines.get(i).name)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void setSearchEngines(JSONObject data) {
+ ThreadUtils.assertOnUiThread();
+
+ // 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");
+
+ ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>();
+ for (int i = 0; i < engines.length(); i++) {
+ final JSONObject engineJSON = engines.getJSONObject(i);
+ final SearchEngine engine = new SearchEngine((Context) getActivity(), engineJSON);
+
+ if (engine.name.equals(suggestEngine) && suggestTemplate != null) {
+ // Suggest engine should be at the front of the list.
+ // We're baking in an assumption here that the suggest engine
+ // is also the default engine.
+ searchEngines.add(0, engine);
+
+ ensureSuggestClientIsSet(suggestTemplate);
+ } else {
+ searchEngines.add(engine);
+ }
+ }
+
+ // checking if the new searchEngine is different from mSearchEngine, will have to re-layout if yes
+ boolean change = shouldUpdateSearchEngine(searchEngines);
+
+ if (mAdapter != null && change) {
+ mSearchEngines = Collections.unmodifiableList(searchEngines);
+ mLastLocale = Locale.getDefault();
+ updateSearchEngineBar();
+
+ mAdapter.notifyDataSetChanged();
+ }
+
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Show suggestions opt-in prompt only if suggestions are not enabled yet,
+ // user hasn't been prompted and we're not on a private browsing tab.
+ // The prompt might have been inflated already when this view was previously called.
+ // Remove the opt-in prompt if it has been inflated in the view and dealt with by the user,
+ // or if we're on a private browsing tab
+ if (!mSuggestionsEnabled && !suggestionsPrompted && !isPrivate) {
+ showSuggestionsOptIn();
+ } else {
+ removeSuggestionsOptIn();
+ }
+
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error getting search engine JSON", e);
+ }
+
+ filterSuggestions();
+ }
+
+ private void updateSearchEngineBar() {
+ final int primaryEngineCount = getPrimaryEngineCount();
+
+ if (primaryEngineCount < mSearchEngines.size()) {
+ mSearchEngineBar.setSearchEngines(
+ mSearchEngines.subList(primaryEngineCount, mSearchEngines.size())
+ );
+ mSearchEngineBar.setVisibility(View.VISIBLE);
+ } else {
+ mSearchEngineBar.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onSearchBarClickListener(final SearchEngine searchEngine) {
+ final TelemetryContract.Method method = TelemetryContract.Method.LIST_ITEM;
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, "searchenginebar");
+ mSearchListener.onSearch(searchEngine, mSearchTerm, method);
+ }
+
+ private void ensureSuggestClientIsSet(final String suggestTemplate) {
+ // Don't update the suggestClient if we already have a client with the correct template
+ if (mSuggestClient != null && suggestTemplate.equals(mSuggestClient.getSuggestTemplate())) {
+ return;
+ }
+
+ mSuggestClient = sSuggestClientFactory.getSuggestClient(getActivity(), suggestTemplate, SUGGESTION_TIMEOUT, NETWORK_SUGGESTION_MAX);
+ }
+
+ private void showSuggestionsOptIn() {
+ // Only make the ViewStub visible again if it has already previously been shown.
+ // (An inflated ViewStub is removed from the View hierarchy so a second call to findViewById will return null,
+ // which also further necessitates handling this separately.)
+ if (mSuggestionsOptInPrompt != null) {
+ mSuggestionsOptInPrompt.setVisibility(View.VISIBLE);
+ return;
+ }
+
+ 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));
+
+ 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);
+
+ // If the prompt gains focus, automatically pass focus to the
+ // yes button in the prompt.
+ final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+ prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ yesButton.requestFocus();
+ }
+ }
+ });
+ }
+
+ private void removeSuggestionsOptIn() {
+ if (mSuggestionsOptInPrompt == null) {
+ return;
+ }
+
+ mSuggestionsOptInPrompt.setVisibility(View.GONE);
+ }
+
+ 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);
+ }
+ }
+ });
+
+ PrefsHelper.setPref("browser.search.suggest.prompted", true);
+ PrefsHelper.setPref("browser.search.suggest.enabled", enabled);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, (enabled ? "suggestions_optin_yes" : "suggestions_optin_no"));
+
+ 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;
+
+ // Reset the view height
+ mView.getLayoutParams().height = LayoutParams.MATCH_PARENT;
+
+ // Show search suggestions and update them
+ if (enabled) {
+ mSuggestionsEnabled = enabled;
+ mAnimateSuggestions = true;
+ mAdapter.notifyDataSetChanged();
+ filterSuggestions();
+ }
+ }
+ });
+ }
+ });
+
+ prompt.startAnimation(slideAnimation);
+ mSuggestionsOptInPrompt.startAnimation(shrinkAnimation);
+ mList.startAnimation(shrinkAnimation);
+ }
+
+ private int getPrimaryEngineCount() {
+ return mSearchEngines.size() > 0 ? 1 : 0;
+ }
+
+ private void restartSearchLoader() {
+ SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void initSearchLoader() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ public void filter(String searchTerm, AutocompleteHandler handler) {
+ if (TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm);
+
+ mSearchTerm = searchTerm;
+ mAutocompleteHandler = handler;
+
+ if (mAdapter != null) {
+ if (isNewFilter) {
+ // 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
+ restartSearchLoader();
+ filterSuggestions();
+ } else {
+ // The search term hasn't changed, simply reuse any existing
+ // loader for the current search term. This will ensure autocompletion
+ // is consistently triggered (see bug 933739).
+ initSearchLoader();
+ }
+ }
+ }
+
+ abstract private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> {
+ protected final String mSearchTerm;
+ private ArrayList<String> mSuggestions;
+
+ public SuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context);
+ mSearchTerm = searchTerm;
+ }
+
+ @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 static class SearchEngineSuggestionAsyncLoader extends SuggestionAsyncLoader {
+ private final SuggestClient mSuggestClient;
+
+ public SearchEngineSuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
+ super(context, searchTerm);
+ mSuggestClient = suggestClient;
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ return mSuggestClient.query(mSearchTerm);
+ }
+ }
+
+ private static class SearchHistorySuggestionAsyncLoader extends SuggestionAsyncLoader {
+ public SearchHistorySuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context, searchTerm);
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ final ContentResolver cr = getContext().getContentResolver();
+
+ String[] columns = new String[] { BrowserContract.SearchHistory.QUERY };
+ String actualQuery = BrowserContract.SearchHistory.QUERY + " LIKE ?";
+ String[] queryArgs = new String[] { '%' + mSearchTerm + '%' };
+
+ // For deduplication, the worst case is that all the first NETWORK_SUGGESTION_MAX history suggestions are duplicates
+ // of search engine suggestions, and the there is a duplicate for the search term itself. A duplicate of the
+ // search term can occur if the user has previously searched for the same thing.
+ final int maxSavedSuggestions = NETWORK_SUGGESTION_MAX + 1 + getContext().getResources().getInteger(R.integer.max_saved_suggestions);
+
+ final String sortOrderAndLimit = BrowserContract.SearchHistory.DATE + " DESC LIMIT " + maxSavedSuggestions;
+ final Cursor result = cr.query(BrowserContract.SearchHistory.CONTENT_URI, columns, actualQuery, queryArgs, sortOrderAndLimit);
+
+ if (result == null) {
+ return new ArrayList<>();
+ }
+
+ final ArrayList<String> savedSuggestions = new ArrayList<>();
+ try {
+ if (result.moveToFirst()) {
+ final int searchColumn = result.getColumnIndexOrThrow(BrowserContract.SearchHistory.QUERY);
+ do {
+ final String savedSearch = result.getString(searchColumn);
+ savedSuggestions.add(savedSearch);
+ } while (result.moveToNext());
+ }
+ } finally {
+ result.close();
+ }
+
+ return savedSuggestions;
+ }
+ }
+
+ private class SearchAdapter extends MultiTypeCursorAdapter {
+ private static final int ROW_SEARCH = 0;
+ private static final int ROW_STANDARD = 1;
+ private static final int ROW_SUGGEST = 2;
+
+ public SearchAdapter(Context context) {
+ super(context, null, new int[] { ROW_STANDARD,
+ ROW_SEARCH,
+ ROW_SUGGEST },
+ new int[] { R.layout.home_item_row,
+ R.layout.home_search_item_row,
+ R.layout.home_search_item_row });
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position < getPrimaryEngineCount()) {
+ if (mSuggestionsEnabled && mSearchEngines.get(position).hasSuggestions()) {
+ // Give suggestion views their own type to prevent them from
+ // sharing other recycled search result views. Using other
+ // recycled views for the suggestion row can break animations
+ // (bug 815937).
+
+ return ROW_SUGGEST;
+ } else {
+ return ROW_SEARCH;
+ }
+ }
+
+ return ROW_STANDARD;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ // If we're using a gamepad or keyboard, allow the row to be
+ // focused so it can pass the focus to its child suggestion views.
+ if (!mList.isInTouchMode()) {
+ return true;
+ }
+
+ // 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.
+
+ if (position < getPrimaryEngineCount()) {
+ return !mSearchEngines.get(position).hasSuggestions();
+ }
+
+ 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;
+ }
+
+ return resultCount + getPrimaryEngineCount();
+ }
+
+ @Override
+ public void bindView(View view, Context context, int position) {
+ final int type = getItemViewType(position);
+
+ if (type == ROW_SEARCH || type == ROW_SUGGEST) {
+ final SearchEngineRow row = (SearchEngineRow) view;
+ row.setOnUrlOpenListener(mUrlOpenListener);
+ row.setOnSearchListener(mSearchListener);
+ row.setOnEditSuggestionListener(mEditSuggestionListener);
+ row.setSearchTerm(mSearchTerm);
+
+ final SearchEngine engine = mSearchEngines.get(position);
+ final boolean haveSuggestions = (engine.hasSuggestions() || !mSearchHistorySuggestions.isEmpty());
+ final boolean animate = (mAnimateSuggestions && haveSuggestions);
+ row.updateSuggestions(mSuggestionsEnabled, engine, mSearchHistorySuggestions, animate);
+ if (animate) {
+ // Only animate suggestions the first time they are shown
+ mAnimateSuggestions = false;
+ }
+ } else {
+ // Account for the search engines
+ position -= getPrimaryEngineCount();
+
+ final Cursor c = getCursor(position);
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(c);
+ }
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ return SearchLoader.createInstance(getActivity(), args);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(c);
+
+ // We should handle autocompletion based on the search term
+ // associated with the loader that has just provided
+ // the results.
+ SearchCursorLoader searchLoader = (SearchCursorLoader) loader;
+ handleAutocomplete(searchLoader.getSearchTerm(), c);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(null);
+ }
+ }
+ }
+
+ private class SearchEngineSuggestionLoaderCallbacks 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 SearchEngineSuggestionAsyncLoader(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>());
+ }
+ }
+
+ private class SearchHistorySuggestionLoaderCallbacks 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 SearchHistorySuggestionAsyncLoader(getActivity(), mSearchTerm);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+ setSavedSuggestions(suggestions);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<ArrayList<String>> loader) {
+ setSavedSuggestions(new ArrayList<String>());
+ }
+ }
+
+ private static class ListSelectionListener implements View.OnFocusChangeListener,
+ AdapterView.OnItemSelectedListener {
+ private SearchEngineRow mSelectedEngineRow;
+
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ View selectedRow = ((ListView) v).getSelectedView();
+ if (selectedRow != null) {
+ selectRow(selectedRow);
+ }
+ } else {
+ deselectRow();
+ }
+ }
+
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ deselectRow();
+ selectRow(view);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ deselectRow();
+ }
+
+ private void selectRow(View row) {
+ if (row instanceof SearchEngineRow) {
+ mSelectedEngineRow = (SearchEngineRow) row;
+ mSelectedEngineRow.onSelected();
+ }
+ }
+
+ private void deselectRow() {
+ if (mSelectedEngineRow != null) {
+ mSelectedEngineRow.onDeselected();
+ mSelectedEngineRow = null;
+ }
+ }
+ }
+
+ /**
+ * HomeSearchListView is a list view for displaying search engine results on the awesome screen.
+ */
+ public static class HomeSearchListView extends HomeListView {
+ public HomeSearchListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.homeListViewStyle);
+ }
+
+ public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ // Dismiss the soft keyboard.
+ requestFocus();
+ }
+
+ return super.onTouchEvent(event);
+ }
+ }
+}