michael@0: /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- michael@0: * This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.home; michael@0: michael@0: import org.mozilla.gecko.GeckoAppShell; michael@0: import org.mozilla.gecko.GeckoEvent; michael@0: import org.mozilla.gecko.PrefsHelper; michael@0: import org.mozilla.gecko.R; michael@0: import org.mozilla.gecko.Tab; michael@0: import org.mozilla.gecko.Tabs; michael@0: import org.mozilla.gecko.Telemetry; michael@0: import org.mozilla.gecko.TelemetryContract; michael@0: import org.mozilla.gecko.db.BrowserDB.URLColumns; michael@0: import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; michael@0: import org.mozilla.gecko.home.SearchEngine; michael@0: import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader; michael@0: import org.mozilla.gecko.mozglue.RobocopTarget; michael@0: import org.mozilla.gecko.toolbar.AutocompleteHandler; michael@0: import org.mozilla.gecko.util.GeckoEventListener; michael@0: import org.mozilla.gecko.util.StringUtils; michael@0: import org.mozilla.gecko.util.ThreadUtils; michael@0: michael@0: import org.json.JSONArray; michael@0: import org.json.JSONException; michael@0: import org.json.JSONObject; michael@0: michael@0: import android.app.Activity; michael@0: import android.content.Context; michael@0: import android.database.Cursor; michael@0: import android.net.Uri; michael@0: import android.os.Bundle; michael@0: import android.support.v4.app.LoaderManager.LoaderCallbacks; michael@0: import android.support.v4.content.AsyncTaskLoader; michael@0: import android.support.v4.content.Loader; michael@0: import android.text.TextUtils; michael@0: import android.util.AttributeSet; michael@0: import android.util.Log; michael@0: import android.view.LayoutInflater; michael@0: import android.view.MotionEvent; michael@0: import android.view.View; michael@0: import android.view.View.OnClickListener; michael@0: import android.view.ViewGroup; michael@0: import android.view.ViewStub; michael@0: import android.view.WindowManager; michael@0: import android.view.WindowManager.LayoutParams; michael@0: import android.view.animation.AccelerateInterpolator; michael@0: import android.view.animation.Animation; michael@0: import android.view.animation.TranslateAnimation; michael@0: import android.widget.AdapterView; michael@0: import android.widget.LinearLayout; michael@0: import android.widget.ListView; michael@0: import android.widget.TextView; michael@0: michael@0: import java.util.ArrayList; michael@0: import java.util.EnumSet; michael@0: michael@0: /** michael@0: * Fragment that displays frecency search results in a ListView. michael@0: */ michael@0: public class BrowserSearch extends HomeFragment michael@0: implements GeckoEventListener { michael@0: // Logging tag name michael@0: private static final String LOGTAG = "GeckoBrowserSearch"; michael@0: michael@0: // Cursor loader ID for search query michael@0: private static final int LOADER_ID_SEARCH = 0; michael@0: michael@0: // AsyncTask loader ID for suggestion query michael@0: private static final int LOADER_ID_SUGGESTION = 1; michael@0: michael@0: // Timeout for the suggestion client to respond michael@0: private static final int SUGGESTION_TIMEOUT = 3000; michael@0: michael@0: // Maximum number of results returned by the suggestion client michael@0: private static final int SUGGESTION_MAX = 3; michael@0: michael@0: // The maximum number of rows deep in a search we'll dig michael@0: // for an autocomplete result michael@0: private static final int MAX_AUTOCOMPLETE_SEARCH = 20; michael@0: michael@0: // Length of https:// + 1 required to make autocomplete michael@0: // fill in the domain, for both http:// and https:// michael@0: private static final int HTTPS_PREFIX_LENGTH = 9; michael@0: michael@0: // Duration for fade-in animation michael@0: private static final int ANIMATION_DURATION = 250; michael@0: michael@0: // Holds the current search term to use in the query michael@0: private volatile String mSearchTerm; michael@0: michael@0: // Adapter for the list of search results michael@0: private SearchAdapter mAdapter; michael@0: michael@0: // The view shown by the fragment michael@0: private LinearLayout mView; michael@0: michael@0: // The list showing search results michael@0: private HomeListView mList; michael@0: michael@0: // Client that performs search suggestion queries michael@0: private volatile SuggestClient mSuggestClient; michael@0: michael@0: // List of search engines from gecko michael@0: private ArrayList mSearchEngines; michael@0: michael@0: // Whether search suggestions are enabled or not michael@0: private boolean mSuggestionsEnabled; michael@0: michael@0: // Callbacks used for the search loader michael@0: private CursorLoaderCallbacks mCursorLoaderCallbacks; michael@0: michael@0: // Callbacks used for the search suggestion loader michael@0: private SuggestionLoaderCallbacks mSuggestionLoaderCallbacks; michael@0: michael@0: // Autocomplete handler used when filtering results michael@0: private AutocompleteHandler mAutocompleteHandler; michael@0: michael@0: // On URL open listener michael@0: private OnUrlOpenListener mUrlOpenListener; michael@0: michael@0: // On search listener michael@0: private OnSearchListener mSearchListener; michael@0: michael@0: // On edit suggestion listener michael@0: private OnEditSuggestionListener mEditSuggestionListener; michael@0: michael@0: // Whether the suggestions will fade in when shown michael@0: private boolean mAnimateSuggestions; michael@0: michael@0: // Opt-in prompt view for search suggestions michael@0: private View mSuggestionsOptInPrompt; michael@0: michael@0: public interface OnSearchListener { michael@0: public void onSearch(SearchEngine engine, String text); michael@0: } michael@0: michael@0: public interface OnEditSuggestionListener { michael@0: public void onEditSuggestion(String suggestion); michael@0: } michael@0: michael@0: public static BrowserSearch newInstance() { michael@0: BrowserSearch browserSearch = new BrowserSearch(); michael@0: michael@0: final Bundle args = new Bundle(); michael@0: args.putBoolean(HomePager.CAN_LOAD_ARG, true); michael@0: browserSearch.setArguments(args); michael@0: michael@0: return browserSearch; michael@0: } michael@0: michael@0: public BrowserSearch() { michael@0: mSearchTerm = ""; michael@0: } michael@0: michael@0: @Override michael@0: public void onAttach(Activity activity) { michael@0: super.onAttach(activity); michael@0: michael@0: try { michael@0: mUrlOpenListener = (OnUrlOpenListener) activity; michael@0: } catch (ClassCastException e) { michael@0: throw new ClassCastException(activity.toString() michael@0: + " must implement BrowserSearch.OnUrlOpenListener"); michael@0: } michael@0: michael@0: try { michael@0: mSearchListener = (OnSearchListener) activity; michael@0: } catch (ClassCastException e) { michael@0: throw new ClassCastException(activity.toString() michael@0: + " must implement BrowserSearch.OnSearchListener"); michael@0: } michael@0: michael@0: try { michael@0: mEditSuggestionListener = (OnEditSuggestionListener) activity; michael@0: } catch (ClassCastException e) { michael@0: throw new ClassCastException(activity.toString() michael@0: + " must implement BrowserSearch.OnEditSuggestionListener"); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void onDetach() { michael@0: super.onDetach(); michael@0: michael@0: mAutocompleteHandler = null; michael@0: mUrlOpenListener = null; michael@0: mSearchListener = null; michael@0: mEditSuggestionListener = null; michael@0: } michael@0: michael@0: @Override michael@0: public void onCreate(Bundle savedInstanceState) { michael@0: super.onCreate(savedInstanceState); michael@0: michael@0: mSearchEngines = new ArrayList(); michael@0: } michael@0: michael@0: @Override michael@0: public void onDestroy() { michael@0: super.onDestroy(); michael@0: michael@0: mSearchEngines = null; michael@0: } michael@0: michael@0: @Override michael@0: public void onStart() { michael@0: super.onStart(); michael@0: michael@0: // Adjusting the window size when showing the keyboard results in the underlying michael@0: // activity being painted when the keyboard is hidden (bug 933422). This can be michael@0: // prevented by not resizing the window. michael@0: getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); michael@0: } michael@0: michael@0: @Override michael@0: public void onStop() { michael@0: super.onStop(); michael@0: michael@0: getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); michael@0: } michael@0: michael@0: @Override michael@0: public void onResume() { michael@0: super.onResume(); michael@0: michael@0: Telemetry.startUISession(TelemetryContract.Session.FRECENCY); michael@0: } michael@0: michael@0: @Override michael@0: public void onPause() { michael@0: super.onPause(); michael@0: michael@0: Telemetry.stopUISession(TelemetryContract.Session.FRECENCY); michael@0: } michael@0: michael@0: @Override michael@0: public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { michael@0: // All list views are styled to look the same with a global activity theme. michael@0: // If the style of the list changes, inflate it from an XML. michael@0: mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false); michael@0: mList = (HomeListView) mView.findViewById(R.id.home_list_view); michael@0: michael@0: return mView; michael@0: } michael@0: michael@0: @Override michael@0: public void onDestroyView() { michael@0: super.onDestroyView(); michael@0: michael@0: unregisterEventListener("SearchEngines:Data"); michael@0: michael@0: mList.setAdapter(null); michael@0: mList = null; michael@0: michael@0: mView = null; michael@0: mSuggestionsOptInPrompt = null; michael@0: mSuggestClient = null; michael@0: } michael@0: michael@0: @Override michael@0: public void onViewCreated(View view, Bundle savedInstanceState) { michael@0: super.onViewCreated(view, savedInstanceState); michael@0: mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH); michael@0: michael@0: mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { michael@0: @Override michael@0: public void onItemClick(AdapterView parent, View view, int position, long id) { michael@0: // Perform the user-entered search if the user clicks on a search engine row. michael@0: // This row will be disabled if suggestions (in addition to the user-entered term) are showing. michael@0: if (view instanceof SearchEngineRow) { michael@0: ((SearchEngineRow) view).performUserEnteredSearch(); michael@0: return; michael@0: } michael@0: michael@0: // Account for the search engine rows. michael@0: position -= getSuggestEngineCount(); michael@0: final Cursor c = mAdapter.getCursor(position); michael@0: final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL)); michael@0: michael@0: // The "urlbar" and "frecency" sessions can be open at the same time. Use the LIST_ITEM michael@0: // method to set this LOAD_URL event apart from the case where the user commits what's in michael@0: // the url bar. michael@0: Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM); michael@0: michael@0: // This item is a TwoLinePageRow, so we allow switch-to-tab. michael@0: mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); michael@0: } michael@0: }); michael@0: michael@0: mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { michael@0: @Override michael@0: public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { michael@0: // Don't do anything when the user long-clicks on a search engine row. michael@0: if (view instanceof SearchEngineRow) { michael@0: return true; michael@0: } michael@0: michael@0: // Account for the search engine rows. michael@0: position -= getSuggestEngineCount(); michael@0: return mList.onItemLongClick(parent, view, position, id); michael@0: } michael@0: }); michael@0: michael@0: final ListSelectionListener listener = new ListSelectionListener(); michael@0: mList.setOnItemSelectedListener(listener); michael@0: mList.setOnFocusChangeListener(listener); michael@0: michael@0: mList.setOnKeyListener(new View.OnKeyListener() { michael@0: @Override michael@0: public boolean onKey(View v, int keyCode, android.view.KeyEvent event) { michael@0: final View selected = mList.getSelectedView(); michael@0: michael@0: if (selected instanceof SearchEngineRow) { michael@0: return selected.onKeyDown(keyCode, event); michael@0: } michael@0: return false; michael@0: } michael@0: }); michael@0: michael@0: registerForContextMenu(mList); michael@0: registerEventListener("SearchEngines:Data"); michael@0: michael@0: GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:GetVisible", null)); michael@0: } michael@0: michael@0: @Override michael@0: public void onActivityCreated(Bundle savedInstanceState) { michael@0: super.onActivityCreated(savedInstanceState); michael@0: michael@0: // Intialize the search adapter michael@0: mAdapter = new SearchAdapter(getActivity()); michael@0: mList.setAdapter(mAdapter); michael@0: michael@0: // Only create an instance when we need it michael@0: mSuggestionLoaderCallbacks = null; michael@0: michael@0: // Create callbacks before the initial loader is started michael@0: mCursorLoaderCallbacks = new CursorLoaderCallbacks(); michael@0: loadIfVisible(); michael@0: } michael@0: michael@0: @Override michael@0: public void handleMessage(String event, final JSONObject message) { michael@0: if (event.equals("SearchEngines:Data")) { michael@0: ThreadUtils.postToUiThread(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: setSearchEngines(message); michael@0: } michael@0: }); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: protected void load() { michael@0: SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); michael@0: } michael@0: michael@0: private void handleAutocomplete(String searchTerm, Cursor c) { michael@0: if (c == null || michael@0: mAutocompleteHandler == null || michael@0: TextUtils.isEmpty(searchTerm)) { michael@0: return; michael@0: } michael@0: michael@0: // Avoid searching the path if we don't have to. Currently just michael@0: // decided by whether there is a '/' character in the string. michael@0: final boolean searchPath = searchTerm.indexOf('/') > 0; michael@0: final String autocompletion = findAutocompletion(searchTerm, c, searchPath); michael@0: michael@0: if (autocompletion == null || mAutocompleteHandler == null) { michael@0: return; michael@0: } michael@0: michael@0: // Prefetch auto-completed domain since it's a likely target michael@0: GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", "http://" + autocompletion)); michael@0: michael@0: mAutocompleteHandler.onAutocomplete(autocompletion); michael@0: mAutocompleteHandler = null; michael@0: } michael@0: michael@0: /** michael@0: * Returns the substring of a provided URI, starting at the given offset, michael@0: * and extending up to the end of the path segment in which the provided michael@0: * index is found. michael@0: * michael@0: * For example, given michael@0: * michael@0: * "www.reddit.com/r/boop/abcdef", 0, ? michael@0: * michael@0: * this method returns michael@0: * michael@0: * ?=2: "www.reddit.com/" michael@0: * ?=17: "www.reddit.com/r/boop/" michael@0: * ?=21: "www.reddit.com/r/boop/" michael@0: * ?=22: "www.reddit.com/r/boop/abcdef" michael@0: * michael@0: */ michael@0: private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) { michael@0: final int afterEnd = url.length(); michael@0: michael@0: // We want to include the trailing slash, but not other characters. michael@0: int chop = url.indexOf('/', begin); michael@0: if (chop != -1) { michael@0: ++chop; michael@0: if (chop < offset) { michael@0: // This isn't supposed to happen. Fall back to returning the whole damn thing. michael@0: return url; michael@0: } michael@0: } else { michael@0: chop = url.indexOf('?', begin); michael@0: if (chop == -1) { michael@0: chop = url.indexOf('#', begin); michael@0: } michael@0: if (chop == -1) { michael@0: chop = afterEnd; michael@0: } michael@0: } michael@0: michael@0: return url.substring(offset, chop); michael@0: } michael@0: michael@0: private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) { michael@0: if (!c.moveToFirst()) { michael@0: return null; michael@0: } michael@0: michael@0: final int searchLength = searchTerm.length(); michael@0: final int urlIndex = c.getColumnIndexOrThrow(URLColumns.URL); michael@0: int searchCount = 0; michael@0: michael@0: do { michael@0: final String url = c.getString(urlIndex); michael@0: michael@0: if (searchCount == 0) { michael@0: // Prefetch the first item in the list since it's weighted the highest michael@0: GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Session:Prefetch", url.toString())); michael@0: } michael@0: michael@0: // Does the completion match against the whole URL? This will match michael@0: // about: pages, as well as user input including "http://...". michael@0: if (url.startsWith(searchTerm)) { michael@0: return uriSubstringUpToMatchedPath(url, 0, michael@0: (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH); michael@0: } michael@0: michael@0: final Uri uri = Uri.parse(url); michael@0: final String host = uri.getHost(); michael@0: michael@0: // Host may be null for about pages. michael@0: if (host == null) { michael@0: continue; michael@0: } michael@0: michael@0: if (host.startsWith(searchTerm)) { michael@0: return host + "/"; michael@0: } michael@0: michael@0: final String strippedHost = StringUtils.stripCommonSubdomains(host); michael@0: if (strippedHost.startsWith(searchTerm)) { michael@0: return strippedHost + "/"; michael@0: } michael@0: michael@0: ++searchCount; michael@0: michael@0: if (!searchPath) { michael@0: continue; michael@0: } michael@0: michael@0: // Otherwise, if we're matching paths, let's compare against the string itself. michael@0: final int hostOffset = url.indexOf(strippedHost); michael@0: if (hostOffset == -1) { michael@0: // This was a URL string that parsed to a different host (normalized?). michael@0: // Give up. michael@0: continue; michael@0: } michael@0: michael@0: // We already matched the non-stripped host, so now we're michael@0: // substring-searching in the part of the URL without the common michael@0: // subdomains. michael@0: if (url.startsWith(searchTerm, hostOffset)) { michael@0: // Great! Return including the rest of the path segment. michael@0: return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength); michael@0: } michael@0: } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext()); michael@0: michael@0: return null; michael@0: } michael@0: michael@0: private void filterSuggestions() { michael@0: if (mSuggestClient == null || !mSuggestionsEnabled) { michael@0: return; michael@0: } michael@0: michael@0: if (mSuggestionLoaderCallbacks == null) { michael@0: mSuggestionLoaderCallbacks = new SuggestionLoaderCallbacks(); michael@0: } michael@0: michael@0: getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSuggestionLoaderCallbacks); michael@0: } michael@0: michael@0: private void setSuggestions(ArrayList suggestions) { michael@0: mSearchEngines.get(0).setSuggestions(suggestions); michael@0: mAdapter.notifyDataSetChanged(); michael@0: } michael@0: michael@0: private void setSearchEngines(JSONObject data) { michael@0: // This method is called via a Runnable posted from the Gecko thread, so michael@0: // it's possible the fragment and/or its view has been destroyed by the michael@0: // time we get here. If so, just abort. michael@0: if (mView == null) { michael@0: return; michael@0: } michael@0: michael@0: try { michael@0: final JSONObject suggest = data.getJSONObject("suggest"); michael@0: final String suggestEngine = suggest.optString("engine", null); michael@0: final String suggestTemplate = suggest.optString("template", null); michael@0: final boolean suggestionsPrompted = suggest.getBoolean("prompted"); michael@0: final JSONArray engines = data.getJSONArray("searchEngines"); michael@0: michael@0: mSuggestionsEnabled = suggest.getBoolean("enabled"); michael@0: michael@0: ArrayList searchEngines = new ArrayList(); michael@0: for (int i = 0; i < engines.length(); i++) { michael@0: final JSONObject engineJSON = engines.getJSONObject(i); michael@0: final SearchEngine engine = new SearchEngine(engineJSON); michael@0: michael@0: if (engine.name.equals(suggestEngine) && suggestTemplate != null) { michael@0: // Suggest engine should be at the front of the list. michael@0: searchEngines.add(0, engine); michael@0: michael@0: // The only time Tabs.getInstance().getSelectedTab() should michael@0: // be null is when we're restoring after a crash. We should michael@0: // never restore private tabs when that happens, so it michael@0: // should be safe to assume that null means non-private. michael@0: Tab tab = Tabs.getInstance().getSelectedTab(); michael@0: final boolean isPrivate = (tab != null && tab.isPrivate()); michael@0: michael@0: // Only create a new instance of SuggestClient if it hasn't been michael@0: // set yet. e.g. Robocop tests might set it directly before search michael@0: // engines are loaded. michael@0: if (mSuggestClient == null && !isPrivate) { michael@0: setSuggestClient(new SuggestClient(getActivity(), suggestTemplate, michael@0: SUGGESTION_TIMEOUT, SUGGESTION_MAX)); michael@0: } michael@0: } else { michael@0: searchEngines.add(engine); michael@0: } michael@0: } michael@0: michael@0: mSearchEngines = searchEngines; michael@0: michael@0: if (mAdapter != null) { michael@0: mAdapter.notifyDataSetChanged(); michael@0: } michael@0: michael@0: // Show suggestions opt-in prompt only if suggestions are not enabled yet, michael@0: // user hasn't been prompted and we're not on a private browsing tab. michael@0: if (!mSuggestionsEnabled && !suggestionsPrompted && mSuggestClient != null) { michael@0: showSuggestionsOptIn(); michael@0: } michael@0: } catch (JSONException e) { michael@0: Log.e(LOGTAG, "Error getting search engine JSON", e); michael@0: } michael@0: michael@0: filterSuggestions(); michael@0: } michael@0: michael@0: /** michael@0: * Sets the private SuggestClient instance. Should only be called if the suggestClient is michael@0: * null (i.e. has not yet been initialized or has been nulled). Non-private access is michael@0: * for testing purposes only. michael@0: */ michael@0: @RobocopTarget michael@0: public void setSuggestClient(final SuggestClient client) { michael@0: if (mSuggestClient != null) { michael@0: throw new IllegalStateException("Can only set the SuggestClient if it has not " + michael@0: "yet been initialized!"); michael@0: } michael@0: mSuggestClient = client; michael@0: } michael@0: michael@0: private void showSuggestionsOptIn() { michael@0: // Return if the ViewStub was already inflated - an inflated ViewStub is removed from the michael@0: // View hierarchy so a second call to findViewById will return null. michael@0: if (mSuggestionsOptInPrompt != null) { michael@0: return; michael@0: } michael@0: michael@0: mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate(); michael@0: michael@0: TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title); michael@0: promptText.setText(getResources().getString(R.string.suggestions_prompt)); michael@0: michael@0: final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes); michael@0: final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no); michael@0: michael@0: final OnClickListener listener = new OnClickListener() { michael@0: @Override michael@0: public void onClick(View v) { michael@0: // Prevent the buttons from being clicked multiple times (bug 816902) michael@0: yesButton.setOnClickListener(null); michael@0: noButton.setOnClickListener(null); michael@0: michael@0: setSuggestionsEnabled(v == yesButton); michael@0: } michael@0: }; michael@0: michael@0: yesButton.setOnClickListener(listener); michael@0: noButton.setOnClickListener(listener); michael@0: michael@0: // If the prompt gains focus, automatically pass focus to the michael@0: // yes button in the prompt. michael@0: final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); michael@0: prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() { michael@0: @Override michael@0: public void onFocusChange(View v, boolean hasFocus) { michael@0: if (hasFocus) { michael@0: yesButton.requestFocus(); michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: michael@0: private void setSuggestionsEnabled(final boolean enabled) { michael@0: // Clicking the yes/no buttons quickly can cause the click events be michael@0: // queued before the listeners are removed above, so it's possible michael@0: // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt michael@0: // can be null if this happens (bug 828480). michael@0: if (mSuggestionsOptInPrompt == null) { michael@0: return; michael@0: } michael@0: michael@0: // Make suggestions appear immediately after the user opts in michael@0: ThreadUtils.postToBackgroundThread(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: SuggestClient client = mSuggestClient; michael@0: if (client != null) { michael@0: client.query(mSearchTerm); michael@0: } michael@0: } michael@0: }); michael@0: michael@0: // Pref observer in gecko will also set prompted = true michael@0: PrefsHelper.setPref("browser.search.suggest.enabled", enabled); michael@0: michael@0: TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0); michael@0: slideAnimation.setDuration(ANIMATION_DURATION); michael@0: slideAnimation.setInterpolator(new AccelerateInterpolator()); michael@0: slideAnimation.setFillAfter(true); michael@0: final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); michael@0: michael@0: TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight()); michael@0: shrinkAnimation.setDuration(ANIMATION_DURATION); michael@0: shrinkAnimation.setFillAfter(true); michael@0: shrinkAnimation.setStartOffset(slideAnimation.getDuration()); michael@0: shrinkAnimation.setAnimationListener(new Animation.AnimationListener() { michael@0: @Override michael@0: public void onAnimationStart(Animation a) { michael@0: // Increase the height of the view so a gap isn't shown during animation michael@0: mView.getLayoutParams().height = mView.getHeight() + michael@0: mSuggestionsOptInPrompt.getHeight(); michael@0: mView.requestLayout(); michael@0: } michael@0: michael@0: @Override michael@0: public void onAnimationRepeat(Animation a) {} michael@0: michael@0: @Override michael@0: public void onAnimationEnd(Animation a) { michael@0: // Removing the view immediately results in a NPE in michael@0: // dispatchDraw(), possibly because this callback executes michael@0: // before drawing is finished. Posting this as a Runnable fixes michael@0: // the issue. michael@0: mView.post(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: mView.removeView(mSuggestionsOptInPrompt); michael@0: mList.clearAnimation(); michael@0: mSuggestionsOptInPrompt = null; michael@0: michael@0: if (enabled) { michael@0: // Reset the view height michael@0: mView.getLayoutParams().height = LayoutParams.MATCH_PARENT; michael@0: michael@0: mSuggestionsEnabled = enabled; michael@0: mAnimateSuggestions = true; michael@0: mAdapter.notifyDataSetChanged(); michael@0: filterSuggestions(); michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: }); michael@0: michael@0: prompt.startAnimation(slideAnimation); michael@0: mSuggestionsOptInPrompt.startAnimation(shrinkAnimation); michael@0: mList.startAnimation(shrinkAnimation); michael@0: } michael@0: michael@0: private int getSuggestEngineCount() { michael@0: return (TextUtils.isEmpty(mSearchTerm) || mSuggestClient == null || !mSuggestionsEnabled) ? 0 : 1; michael@0: } michael@0: michael@0: private void registerEventListener(String eventName) { michael@0: GeckoAppShell.registerEventListener(eventName, this); michael@0: } michael@0: michael@0: private void unregisterEventListener(String eventName) { michael@0: GeckoAppShell.unregisterEventListener(eventName, this); michael@0: } michael@0: michael@0: private void restartSearchLoader() { michael@0: SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); michael@0: } michael@0: michael@0: private void initSearchLoader() { michael@0: SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); michael@0: } michael@0: michael@0: public void filter(String searchTerm, AutocompleteHandler handler) { michael@0: if (TextUtils.isEmpty(searchTerm)) { michael@0: return; michael@0: } michael@0: michael@0: final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm); michael@0: michael@0: mSearchTerm = searchTerm; michael@0: mAutocompleteHandler = handler; michael@0: michael@0: if (isVisible()) { michael@0: if (isNewFilter) { michael@0: // The adapter depends on the search term to determine its number michael@0: // of items. Make it we notify the view about it. michael@0: mAdapter.notifyDataSetChanged(); michael@0: michael@0: // Restart loaders with the new search term michael@0: restartSearchLoader(); michael@0: filterSuggestions(); michael@0: } else { michael@0: // The search term hasn't changed, simply reuse any existing michael@0: // loader for the current search term. This will ensure autocompletion michael@0: // is consistently triggered (see bug 933739). michael@0: initSearchLoader(); michael@0: } michael@0: } michael@0: } michael@0: michael@0: private static class SuggestionAsyncLoader extends AsyncTaskLoader> { michael@0: private final SuggestClient mSuggestClient; michael@0: private final String mSearchTerm; michael@0: private ArrayList mSuggestions; michael@0: michael@0: public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) { michael@0: super(context); michael@0: mSuggestClient = suggestClient; michael@0: mSearchTerm = searchTerm; michael@0: mSuggestions = null; michael@0: } michael@0: michael@0: @Override michael@0: public ArrayList loadInBackground() { michael@0: return mSuggestClient.query(mSearchTerm); michael@0: } michael@0: michael@0: @Override michael@0: public void deliverResult(ArrayList suggestions) { michael@0: mSuggestions = suggestions; michael@0: michael@0: if (isStarted()) { michael@0: super.deliverResult(mSuggestions); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: protected void onStartLoading() { michael@0: if (mSuggestions != null) { michael@0: deliverResult(mSuggestions); michael@0: } michael@0: michael@0: if (takeContentChanged() || mSuggestions == null) { michael@0: forceLoad(); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: protected void onStopLoading() { michael@0: cancelLoad(); michael@0: } michael@0: michael@0: @Override michael@0: protected void onReset() { michael@0: super.onReset(); michael@0: michael@0: onStopLoading(); michael@0: mSuggestions = null; michael@0: } michael@0: } michael@0: michael@0: private class SearchAdapter extends MultiTypeCursorAdapter { michael@0: private static final int ROW_SEARCH = 0; michael@0: private static final int ROW_STANDARD = 1; michael@0: private static final int ROW_SUGGEST = 2; michael@0: michael@0: public SearchAdapter(Context context) { michael@0: super(context, null, new int[] { ROW_STANDARD, michael@0: ROW_SEARCH, michael@0: ROW_SUGGEST }, michael@0: new int[] { R.layout.home_item_row, michael@0: R.layout.home_search_item_row, michael@0: R.layout.home_search_item_row }); michael@0: } michael@0: michael@0: @Override michael@0: public int getItemViewType(int position) { michael@0: final int engine = getEngineIndex(position); michael@0: michael@0: if (engine == -1) { michael@0: return ROW_STANDARD; michael@0: } else if (engine == 0 && mSuggestionsEnabled) { michael@0: // Give suggestion views their own type to prevent them from michael@0: // sharing other recycled search engine views. Using other michael@0: // recycled views for the suggestion row can break animations michael@0: // (bug 815937). michael@0: return ROW_SUGGEST; michael@0: } michael@0: michael@0: return ROW_SEARCH; michael@0: } michael@0: michael@0: @Override michael@0: public boolean isEnabled(int position) { michael@0: // If we're using a gamepad or keyboard, allow the row to be michael@0: // focused so it can pass the focus to its child suggestion views. michael@0: if (!mList.isInTouchMode()) { michael@0: return true; michael@0: } michael@0: michael@0: // If the suggestion row only contains one item (the user-entered michael@0: // query), allow the entire row to be clickable; clicking the row michael@0: // has the same effect as clicking the single suggestion. If the michael@0: // row contains multiple items, clicking the row will do nothing. michael@0: final int index = getEngineIndex(position); michael@0: if (index != -1) { michael@0: return !mSearchEngines.get(index).hasSuggestions(); michael@0: } michael@0: michael@0: return true; michael@0: } michael@0: michael@0: // Add the search engines to the number of reported results. michael@0: @Override michael@0: public int getCount() { michael@0: final int resultCount = super.getCount(); michael@0: michael@0: // Don't show search engines or suggestions if search field is empty michael@0: if (TextUtils.isEmpty(mSearchTerm)) { michael@0: return resultCount; michael@0: } michael@0: michael@0: return resultCount + mSearchEngines.size(); michael@0: } michael@0: michael@0: @Override michael@0: public void bindView(View view, Context context, int position) { michael@0: final int type = getItemViewType(position); michael@0: michael@0: if (type == ROW_SEARCH || type == ROW_SUGGEST) { michael@0: final SearchEngineRow row = (SearchEngineRow) view; michael@0: row.setOnUrlOpenListener(mUrlOpenListener); michael@0: row.setOnSearchListener(mSearchListener); michael@0: row.setOnEditSuggestionListener(mEditSuggestionListener); michael@0: row.setSearchTerm(mSearchTerm); michael@0: michael@0: final SearchEngine engine = mSearchEngines.get(getEngineIndex(position)); michael@0: final boolean animate = (mAnimateSuggestions && engine.hasSuggestions()); michael@0: row.updateFromSearchEngine(engine, animate); michael@0: if (animate) { michael@0: // Only animate suggestions the first time they are shown michael@0: mAnimateSuggestions = false; michael@0: } michael@0: } else { michael@0: // Account for the search engines michael@0: position -= getSuggestEngineCount(); michael@0: michael@0: final Cursor c = getCursor(position); michael@0: final TwoLinePageRow row = (TwoLinePageRow) view; michael@0: row.updateFromCursor(c); michael@0: } michael@0: } michael@0: michael@0: private int getEngineIndex(int position) { michael@0: final int resultCount = super.getCount(); michael@0: final int suggestEngineCount = getSuggestEngineCount(); michael@0: michael@0: // Return suggest engine index michael@0: if (position < suggestEngineCount) { michael@0: return position; michael@0: } michael@0: michael@0: // Not an engine michael@0: if (position - suggestEngineCount < resultCount) { michael@0: return -1; michael@0: } michael@0: michael@0: // Return search engine index michael@0: return position - resultCount; michael@0: } michael@0: } michael@0: michael@0: private class CursorLoaderCallbacks implements LoaderCallbacks { michael@0: @Override michael@0: public Loader onCreateLoader(int id, Bundle args) { michael@0: return SearchLoader.createInstance(getActivity(), args); michael@0: } michael@0: michael@0: @Override michael@0: public void onLoadFinished(Loader loader, Cursor c) { michael@0: mAdapter.swapCursor(c); michael@0: michael@0: // We should handle autocompletion based on the search term michael@0: // associated with the loader that has just provided michael@0: // the results. michael@0: SearchCursorLoader searchLoader = (SearchCursorLoader) loader; michael@0: handleAutocomplete(searchLoader.getSearchTerm(), c); michael@0: } michael@0: michael@0: @Override michael@0: public void onLoaderReset(Loader loader) { michael@0: mAdapter.swapCursor(null); michael@0: } michael@0: } michael@0: michael@0: private class SuggestionLoaderCallbacks implements LoaderCallbacks> { michael@0: @Override michael@0: public Loader> onCreateLoader(int id, Bundle args) { michael@0: // mSuggestClient is set to null in onDestroyView(), so using it michael@0: // safely here relies on the fact that onCreateLoader() is called michael@0: // synchronously in restartLoader(). michael@0: return new SuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm); michael@0: } michael@0: michael@0: @Override michael@0: public void onLoadFinished(Loader> loader, ArrayList suggestions) { michael@0: setSuggestions(suggestions); michael@0: } michael@0: michael@0: @Override michael@0: public void onLoaderReset(Loader> loader) { michael@0: setSuggestions(new ArrayList()); michael@0: } michael@0: } michael@0: michael@0: private static class ListSelectionListener implements View.OnFocusChangeListener, michael@0: AdapterView.OnItemSelectedListener { michael@0: private SearchEngineRow mSelectedEngineRow; michael@0: michael@0: @Override michael@0: public void onFocusChange(View v, boolean hasFocus) { michael@0: if (hasFocus) { michael@0: View selectedRow = ((ListView) v).getSelectedView(); michael@0: if (selectedRow != null) { michael@0: selectRow(selectedRow); michael@0: } michael@0: } else { michael@0: deselectRow(); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void onItemSelected(AdapterView parent, View view, int position, long id) { michael@0: deselectRow(); michael@0: selectRow(view); michael@0: } michael@0: michael@0: @Override michael@0: public void onNothingSelected(AdapterView parent) { michael@0: deselectRow(); michael@0: } michael@0: michael@0: private void selectRow(View row) { michael@0: if (row instanceof SearchEngineRow) { michael@0: mSelectedEngineRow = (SearchEngineRow) row; michael@0: mSelectedEngineRow.onSelected(); michael@0: } michael@0: } michael@0: michael@0: private void deselectRow() { michael@0: if (mSelectedEngineRow != null) { michael@0: mSelectedEngineRow.onDeselected(); michael@0: mSelectedEngineRow = null; michael@0: } michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * HomeSearchListView is a list view for displaying search engine results on the awesome screen. michael@0: */ michael@0: public static class HomeSearchListView extends HomeListView { michael@0: public HomeSearchListView(Context context, AttributeSet attrs) { michael@0: this(context, attrs, R.attr.homeListViewStyle); michael@0: } michael@0: michael@0: public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) { michael@0: super(context, attrs, defStyle); michael@0: } michael@0: michael@0: @Override michael@0: public boolean onTouchEvent(MotionEvent event) { michael@0: if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { michael@0: // Dismiss the soft keyboard. michael@0: requestFocus(); michael@0: } michael@0: michael@0: return super.onTouchEvent(event); michael@0: } michael@0: } michael@0: }