Thu, 22 Jan 2015 13:21:57 +0100
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);
1007 }
1009 public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) {
1010 super(context, attrs, defStyle);
1011 }
1013 @Override
1014 public boolean onTouchEvent(MotionEvent event) {
1015 if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
1016 // Dismiss the soft keyboard.
1017 requestFocus();
1018 }
1020 return super.onTouchEvent(event);
1021 }
1022 }
1023 }