1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4 
5 package org.mozilla.search.autocomplete;
6 
7 import java.util.ArrayList;
8 import java.util.List;
9 
10 import android.support.v4.content.ContextCompat;
11 import org.mozilla.gecko.R;
12 import org.mozilla.gecko.search.SearchEngine;
13 import org.mozilla.gecko.SuggestClient;
14 import org.mozilla.gecko.Telemetry;
15 import org.mozilla.gecko.TelemetryContract;
16 import org.mozilla.search.AcceptsSearchQuery;
17 import org.mozilla.search.AcceptsSearchQuery.SuggestionAnimation;
18 
19 import android.content.Context;
20 import android.graphics.Rect;
21 import android.os.Bundle;
22 import android.support.v4.app.Fragment;
23 import android.support.v4.app.LoaderManager;
24 import android.support.v4.content.AsyncTaskLoader;
25 import android.support.v4.content.Loader;
26 import android.text.SpannableString;
27 import android.text.style.ForegroundColorSpan;
28 import android.util.Log;
29 import android.view.LayoutInflater;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.AdapterView;
33 import android.widget.ListView;
34 
35 /**
36  * A fragment to show search suggestions.
37  */
38 public class SuggestionsFragment extends Fragment {
39 
40     private static final String LOG_TAG = "SuggestionsFragment";
41 
42     private static final int LOADER_ID_SUGGESTION = 0;
43     private static final String KEY_SEARCH_TERM = "search_term";
44 
45     // Timeout for the suggestion client to respond
46     private static final int SUGGESTION_TIMEOUT = 3000;
47 
48     // Number of search suggestions to show.
49     private static final int SUGGESTION_MAX = 5;
50 
51     public static final String GECKO_SEARCH_TERMS_URL_PARAM = "__searchTerms__";
52 
53     private AcceptsSearchQuery searchListener;
54 
55     // Suggest client gets setup outside of the normal fragment lifecycle, therefore
56     // clients should ensure that this isn't null before using it.
57     private SuggestClient suggestClient;
58     private SuggestionLoaderCallbacks suggestionLoaderCallbacks;
59 
60     private AutoCompleteAdapter autoCompleteAdapter;
61 
62     // Holds the list of search suggestions.
63     private ListView suggestionsList;
64 
SuggestionsFragment()65     public SuggestionsFragment() {
66         // Required empty public constructor
67     }
68 
69     @Override
onAttach(Context context)70     public void onAttach(Context context) {
71         super.onAttach(context);
72 
73         if (context instanceof AcceptsSearchQuery) {
74             searchListener = (AcceptsSearchQuery) context;
75         } else {
76             throw new ClassCastException(context.toString() + " must implement AcceptsSearchQuery.");
77         }
78 
79         suggestionLoaderCallbacks = new SuggestionLoaderCallbacks();
80         autoCompleteAdapter = new AutoCompleteAdapter(context);
81     }
82 
83     @Override
onDetach()84     public void onDetach() {
85         super.onDetach();
86 
87         searchListener = null;
88         suggestionLoaderCallbacks = null;
89         autoCompleteAdapter = null;
90         suggestClient = null;
91     }
92 
93     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)94     public View onCreateView(LayoutInflater inflater, ViewGroup container,
95                              Bundle savedInstanceState) {
96         suggestionsList = (ListView) inflater.inflate(R.layout.search_sugestions, container, false);
97         suggestionsList.setAdapter(autoCompleteAdapter);
98 
99         // Attach listener for tapping on a suggestion.
100         suggestionsList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
101             @Override
102             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
103                 final Suggestion suggestion = (Suggestion) suggestionsList.getItemAtPosition(position);
104 
105                 final Rect startBounds = new Rect();
106                 view.getGlobalVisibleRect(startBounds);
107 
108                 // The user tapped on a suggestion from the search engine.
109                 Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, TelemetryContract.Method.SUGGESTION, position);
110 
111                 searchListener.onSearch(suggestion.value, new SuggestionAnimation() {
112                     @Override
113                     public Rect getStartBounds() {
114                         return startBounds;
115                     }
116                 });
117             }
118         });
119 
120         return suggestionsList;
121     }
122 
123     @Override
onDestroyView()124     public void onDestroyView() {
125         super.onDestroyView();
126 
127         if (null != suggestionsList) {
128             suggestionsList.setOnItemClickListener(null);
129             suggestionsList.setAdapter(null);
130             suggestionsList = null;
131         }
132     }
133 
setEngine(SearchEngine engine)134     public void setEngine(SearchEngine engine) {
135         suggestClient = new SuggestClient(getActivity(), engine.getSuggestionTemplate(GECKO_SEARCH_TERMS_URL_PARAM),
136                                           SUGGESTION_TIMEOUT, SUGGESTION_MAX, true);
137     }
138 
loadSuggestions(String query)139     public void loadSuggestions(String query) {
140         final Bundle args = new Bundle();
141         args.putString(KEY_SEARCH_TERM, query);
142         final LoaderManager loaderManager = getLoaderManager();
143 
144         // Ensure that we don't try to restart a loader that doesn't exist. This becomes
145         // an issue because SuggestionLoaderCallbacks.onCreateLoader can return null
146         // as a loader if we don't have a suggestClient available yet.
147         if (loaderManager.getLoader(LOADER_ID_SUGGESTION) == null) {
148             loaderManager.initLoader(LOADER_ID_SUGGESTION, args, suggestionLoaderCallbacks);
149         } else {
150             loaderManager.restartLoader(LOADER_ID_SUGGESTION, args, suggestionLoaderCallbacks);
151         }
152     }
153 
154     public static class Suggestion {
155 
156         public final String value;
157         public final SpannableString display;
158         public final ForegroundColorSpan colorSpan;
159 
Suggestion(String value, String searchTerm, int suggestionHighlightColor)160         public Suggestion(String value, String searchTerm, int suggestionHighlightColor) {
161             this.value = value;
162 
163             display = new SpannableString(value);
164 
165             colorSpan = new ForegroundColorSpan(suggestionHighlightColor);
166 
167             // Highlight mixed-case matches.
168             final int start = value.toLowerCase().indexOf(searchTerm.toLowerCase());
169             if (start >= 0) {
170                 display.setSpan(colorSpan, start, start + searchTerm.length(), 0);
171             }
172         }
173     }
174 
175     private class SuggestionLoaderCallbacks implements LoaderManager.LoaderCallbacks<List<Suggestion>> {
176         @Override
onCreateLoader(int id, Bundle args)177         public Loader<List<Suggestion>> onCreateLoader(int id, Bundle args) {
178             // We drop the user's search if suggestclient isn't ready. This happens if the
179             // user is really fast and starts typing before we can read shared prefs.
180             if (suggestClient != null) {
181                 return new SuggestionAsyncLoader(getActivity(), suggestClient, args.getString(KEY_SEARCH_TERM));
182             }
183             Log.e(LOG_TAG, "Autocomplete setup failed; suggestClient not ready yet.");
184             return null;
185         }
186 
187         @Override
onLoadFinished(Loader<List<Suggestion>> loader, List<Suggestion> suggestions)188         public void onLoadFinished(Loader<List<Suggestion>> loader, List<Suggestion> suggestions) {
189             // Only show the ListView if there are suggestions in it.
190             if (suggestions.size() > 0) {
191                 autoCompleteAdapter.update(suggestions);
192                 suggestionsList.setVisibility(View.VISIBLE);
193             } else {
194                 suggestionsList.setVisibility(View.INVISIBLE);
195             }
196         }
197 
198         @Override
onLoaderReset(Loader<List<Suggestion>> loader)199         public void onLoaderReset(Loader<List<Suggestion>> loader) { }
200     }
201 
202     private static class SuggestionAsyncLoader extends AsyncTaskLoader<List<Suggestion>> {
203         private final SuggestClient suggestClient;
204         private final String searchTerm;
205         private List<Suggestion> suggestions;
206         private final int suggestionHighlightColor;
207 
SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm)208         public SuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
209             super(context);
210             this.suggestClient = suggestClient;
211             this.searchTerm = searchTerm;
212             this.suggestions = null;
213 
214             // Color of search term match in search suggestion
215             suggestionHighlightColor = ContextCompat.getColor(context, R.color.suggestion_highlight);
216         }
217 
218         @Override
loadInBackground()219         public List<Suggestion> loadInBackground() {
220             final List<String> values = suggestClient.query(searchTerm);
221 
222             final List<Suggestion> result = new ArrayList<Suggestion>(values.size());
223             for (String value : values) {
224                 result.add(new Suggestion(value, searchTerm, suggestionHighlightColor));
225             }
226 
227             return result;
228         }
229 
230         @Override
deliverResult(List<Suggestion> suggestions)231         public void deliverResult(List<Suggestion> suggestions) {
232             this.suggestions = suggestions;
233 
234             if (isStarted()) {
235                 super.deliverResult(suggestions);
236             }
237         }
238 
239         @Override
onStartLoading()240         protected void onStartLoading() {
241             if (suggestions != null) {
242                 deliverResult(suggestions);
243             }
244 
245             if (takeContentChanged() || suggestions == null) {
246                 forceLoad();
247             }
248         }
249 
250         @Override
onStopLoading()251         protected void onStopLoading() {
252             cancelLoad();
253         }
254 
255         @Override
onReset()256         protected void onReset() {
257             super.onReset();
258 
259             onStopLoading();
260             suggestions = null;
261         }
262     }
263 }
264