1 /*
2  * Copyright (C) 2010 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.quicksearchbox.ui;
18 
19 import android.content.Context;
20 import android.database.DataSetObserver;
21 import android.graphics.drawable.Drawable;
22 import android.text.Editable;
23 import android.text.TextUtils;
24 import android.text.TextWatcher;
25 import android.util.AttributeSet;
26 import android.util.Log;
27 import android.view.KeyEvent;
28 import android.view.View;
29 import android.view.inputmethod.CompletionInfo;
30 import android.view.inputmethod.InputMethodManager;
31 import android.widget.AbsListView;
32 import android.widget.ImageButton;
33 import android.widget.ListAdapter;
34 import android.widget.RelativeLayout;
35 import android.widget.TextView;
36 import android.widget.TextView.OnEditorActionListener;
37 
38 import com.android.quicksearchbox.Logger;
39 import com.android.quicksearchbox.QsbApplication;
40 import com.android.quicksearchbox.R;
41 import com.android.quicksearchbox.SearchActivity;
42 import com.android.quicksearchbox.SourceResult;
43 import com.android.quicksearchbox.SuggestionCursor;
44 import com.android.quicksearchbox.Suggestions;
45 import com.android.quicksearchbox.VoiceSearch;
46 
47 import java.util.ArrayList;
48 import java.util.Arrays;
49 
50 public abstract class SearchActivityView extends RelativeLayout {
51     protected static final boolean DBG = false;
52     protected static final String TAG = "QSB.SearchActivityView";
53 
54     // The string used for privateImeOptions to identify to the IME that it should not show
55     // a microphone button since one already exists in the search dialog.
56     // TODO: This should move to android-common or something.
57     private static final String IME_OPTION_NO_MICROPHONE = "nm";
58 
59     protected QueryTextView mQueryTextView;
60     // True if the query was empty on the previous call to updateQuery()
61     protected boolean mQueryWasEmpty = true;
62     protected Drawable mQueryTextEmptyBg;
63     protected Drawable mQueryTextNotEmptyBg;
64 
65     protected SuggestionsListView<ListAdapter> mSuggestionsView;
66     protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter;
67 
68     protected ImageButton mSearchGoButton;
69     protected ImageButton mVoiceSearchButton;
70 
71     protected ButtonsKeyListener mButtonsKeyListener;
72 
73     private boolean mUpdateSuggestions;
74 
75     private QueryListener mQueryListener;
76     private SearchClickListener mSearchClickListener;
77     protected View.OnClickListener mExitClickListener;
78 
SearchActivityView(Context context)79     public SearchActivityView(Context context) {
80         super(context);
81     }
82 
SearchActivityView(Context context, AttributeSet attrs)83     public SearchActivityView(Context context, AttributeSet attrs) {
84         super(context, attrs);
85     }
86 
SearchActivityView(Context context, AttributeSet attrs, int defStyle)87     public SearchActivityView(Context context, AttributeSet attrs, int defStyle) {
88         super(context, attrs, defStyle);
89     }
90 
91     @Override
onFinishInflate()92     protected void onFinishInflate() {
93         mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text);
94 
95         mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
96         mSuggestionsView.setOnScrollListener(new InputMethodCloser());
97         mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
98         mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
99 
100         mSuggestionsAdapter = createSuggestionsAdapter();
101         // TODO: why do we need focus listeners both on the SuggestionsView and the individual
102         // suggestions?
103         mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener());
104 
105         mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
106         mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
107         mVoiceSearchButton.setImageDrawable(getVoiceSearchIcon());
108 
109         mQueryTextView.addTextChangedListener(new SearchTextWatcher());
110         mQueryTextView.setOnEditorActionListener(new QueryTextEditorActionListener());
111         mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
112         mQueryTextEmptyBg = mQueryTextView.getBackground();
113 
114         mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
115 
116         mButtonsKeyListener = new ButtonsKeyListener();
117         mSearchGoButton.setOnKeyListener(mButtonsKeyListener);
118         mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener);
119 
120         mUpdateSuggestions = true;
121     }
122 
onResume()123     public abstract void onResume();
124 
onStop()125     public abstract void onStop();
126 
onPause()127     public void onPause() {
128         // Override if necessary
129     }
130 
start()131     public void start() {
132         mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver());
133         mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter);
134     }
135 
destroy()136     public void destroy() {
137         mSuggestionsView.setSuggestionsAdapter(null);  // closes mSuggestionsAdapter
138     }
139 
140     // TODO: Get rid of this. To make it more easily testable,
141     // the SearchActivityView should not depend on QsbApplication.
getQsbApplication()142     protected QsbApplication getQsbApplication() {
143         return QsbApplication.get(getContext());
144     }
145 
getVoiceSearchIcon()146     protected Drawable getVoiceSearchIcon() {
147         return getResources().getDrawable(R.drawable.ic_btn_speak_now);
148     }
149 
getVoiceSearch()150     protected VoiceSearch getVoiceSearch() {
151         return getQsbApplication().getVoiceSearch();
152     }
153 
createSuggestionsAdapter()154     protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() {
155         return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter(
156                 getQsbApplication().getSuggestionViewFactory()));
157     }
158 
setMaxPromotedResults(int maxPromoted)159     public void setMaxPromotedResults(int maxPromoted) {
160     }
161 
limitResultsToViewHeight()162     public void limitResultsToViewHeight() {
163     }
164 
setQueryListener(QueryListener listener)165     public void setQueryListener(QueryListener listener) {
166         mQueryListener = listener;
167     }
168 
setSearchClickListener(SearchClickListener listener)169     public void setSearchClickListener(SearchClickListener listener) {
170         mSearchClickListener = listener;
171     }
172 
setVoiceSearchButtonClickListener(View.OnClickListener listener)173     public void setVoiceSearchButtonClickListener(View.OnClickListener listener) {
174         if (mVoiceSearchButton != null) {
175             mVoiceSearchButton.setOnClickListener(listener);
176         }
177     }
178 
setSuggestionClickListener(final SuggestionClickListener listener)179     public void setSuggestionClickListener(final SuggestionClickListener listener) {
180         mSuggestionsAdapter.setSuggestionClickListener(listener);
181         mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() {
182             @Override
183             public void onCommitCompletion(int position) {
184                 mSuggestionsAdapter.onSuggestionClicked(position);
185             }
186         });
187     }
188 
setExitClickListener(final View.OnClickListener listener)189     public void setExitClickListener(final View.OnClickListener listener) {
190         mExitClickListener = listener;
191     }
192 
getSuggestions()193     public Suggestions getSuggestions() {
194         return mSuggestionsAdapter.getSuggestions();
195     }
196 
getCurrentSuggestions()197     public SuggestionCursor getCurrentSuggestions() {
198         return mSuggestionsAdapter.getSuggestions().getResult();
199     }
200 
setSuggestions(Suggestions suggestions)201     public void setSuggestions(Suggestions suggestions) {
202         suggestions.acquire();
203         mSuggestionsAdapter.setSuggestions(suggestions);
204     }
205 
clearSuggestions()206     public void clearSuggestions() {
207         mSuggestionsAdapter.setSuggestions(null);
208     }
209 
getQuery()210     public String getQuery() {
211         CharSequence q = mQueryTextView.getText();
212         return q == null ? "" : q.toString();
213     }
214 
isQueryEmpty()215     public boolean isQueryEmpty() {
216         return TextUtils.isEmpty(getQuery());
217     }
218 
219     /**
220      * Sets the text in the query box. Does not update the suggestions.
221      */
setQuery(String query, boolean selectAll)222     public void setQuery(String query, boolean selectAll) {
223         mUpdateSuggestions = false;
224         mQueryTextView.setText(query);
225         mQueryTextView.setTextSelection(selectAll);
226         mUpdateSuggestions = true;
227     }
228 
getActivity()229     protected SearchActivity getActivity() {
230         Context context = getContext();
231         if (context instanceof SearchActivity) {
232             return (SearchActivity) context;
233         } else {
234             return null;
235         }
236     }
237 
hideSuggestions()238     public void hideSuggestions() {
239         mSuggestionsView.setVisibility(GONE);
240     }
241 
showSuggestions()242     public void showSuggestions() {
243         mSuggestionsView.setVisibility(VISIBLE);
244     }
245 
focusQueryTextView()246     public void focusQueryTextView() {
247         mQueryTextView.requestFocus();
248     }
249 
updateUi()250     protected void updateUi() {
251         updateUi(isQueryEmpty());
252     }
253 
updateUi(boolean queryEmpty)254     protected void updateUi(boolean queryEmpty) {
255         updateQueryTextView(queryEmpty);
256         updateSearchGoButton(queryEmpty);
257         updateVoiceSearchButton(queryEmpty);
258     }
259 
updateQueryTextView(boolean queryEmpty)260     protected void updateQueryTextView(boolean queryEmpty) {
261         if (queryEmpty) {
262             mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg);
263             mQueryTextView.setHint(null);
264         } else {
265             mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
266         }
267     }
268 
updateSearchGoButton(boolean queryEmpty)269     private void updateSearchGoButton(boolean queryEmpty) {
270         if (queryEmpty) {
271             mSearchGoButton.setVisibility(View.GONE);
272         } else {
273             mSearchGoButton.setVisibility(View.VISIBLE);
274         }
275     }
276 
updateVoiceSearchButton(boolean queryEmpty)277     protected void updateVoiceSearchButton(boolean queryEmpty) {
278         if (shouldShowVoiceSearch(queryEmpty)
279                 && getVoiceSearch().shouldShowVoiceSearch()) {
280             mVoiceSearchButton.setVisibility(View.VISIBLE);
281             mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
282         } else {
283             mVoiceSearchButton.setVisibility(View.GONE);
284             mQueryTextView.setPrivateImeOptions(null);
285         }
286     }
287 
shouldShowVoiceSearch(boolean queryEmpty)288     protected boolean shouldShowVoiceSearch(boolean queryEmpty) {
289         return queryEmpty;
290     }
291 
292     /**
293      * Hides the input method.
294      */
hideInputMethod()295     protected void hideInputMethod() {
296         InputMethodManager imm = (InputMethodManager)
297                 getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
298         if (imm != null) {
299             imm.hideSoftInputFromWindow(getWindowToken(), 0);
300         }
301     }
302 
considerHidingInputMethod()303     public abstract void considerHidingInputMethod();
304 
showInputMethodForQuery()305     public void showInputMethodForQuery() {
306         mQueryTextView.showInputMethod();
307     }
308 
309     /**
310      * Dismiss the activity if BACK is pressed when the search box is empty.
311      */
312     @Override
dispatchKeyEventPreIme(KeyEvent event)313     public boolean dispatchKeyEventPreIme(KeyEvent event) {
314         SearchActivity activity = getActivity();
315         if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK
316                 && isQueryEmpty()) {
317             KeyEvent.DispatcherState state = getKeyDispatcherState();
318             if (state != null) {
319                 if (event.getAction() == KeyEvent.ACTION_DOWN
320                         && event.getRepeatCount() == 0) {
321                     state.startTracking(event, this);
322                     return true;
323                 } else if (event.getAction() == KeyEvent.ACTION_UP
324                         && !event.isCanceled() && state.isTracking(event)) {
325                     hideInputMethod();
326                     activity.onBackPressed();
327                     return true;
328                 }
329             }
330         }
331         return super.dispatchKeyEventPreIme(event);
332     }
333 
334     /**
335      * If the input method is in fullscreen mode, and the selector corpus
336      * is All or Web, use the web search suggestions as completions.
337      */
updateInputMethodSuggestions()338     protected void updateInputMethodSuggestions() {
339         InputMethodManager imm = (InputMethodManager)
340                 getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
341         if (imm == null || !imm.isFullscreenMode()) return;
342         Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
343         if (suggestions == null) return;
344         CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
345         if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
346         imm.displayCompletions(mQueryTextView, completions);
347     }
348 
webSuggestionsToCompletions(Suggestions suggestions)349     private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
350         SourceResult cursor = suggestions.getWebResult();
351         if (cursor == null) return null;
352         int count = cursor.getCount();
353         ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
354         for (int i = 0; i < count; i++) {
355             cursor.moveTo(i);
356             String text1 = cursor.getSuggestionText1();
357             completions.add(new CompletionInfo(i, i, text1));
358         }
359         return completions.toArray(new CompletionInfo[completions.size()]);
360     }
361 
onSuggestionsChanged()362     protected void onSuggestionsChanged() {
363         updateInputMethodSuggestions();
364     }
365 
onSuggestionKeyDown(SuggestionsAdapter<?> adapter, long suggestionId, int keyCode, KeyEvent event)366     protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter,
367             long suggestionId, int keyCode, KeyEvent event) {
368         // Treat enter or search as a click
369         if (       keyCode == KeyEvent.KEYCODE_ENTER
370                 || keyCode == KeyEvent.KEYCODE_SEARCH
371                 || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
372             if (adapter != null) {
373                 adapter.onSuggestionClicked(suggestionId);
374                 return true;
375             } else {
376                 return false;
377             }
378         }
379 
380         return false;
381     }
382 
onSearchClicked(int method)383     protected boolean onSearchClicked(int method) {
384         if (mSearchClickListener != null) {
385             return mSearchClickListener.onSearchClicked(method);
386         }
387         return false;
388     }
389 
390     /**
391      * Filters the suggestions list when the search text changes.
392      */
393     private class SearchTextWatcher implements TextWatcher {
394         @Override
afterTextChanged(Editable s)395         public void afterTextChanged(Editable s) {
396             boolean empty = s.length() == 0;
397             if (empty != mQueryWasEmpty) {
398                 mQueryWasEmpty = empty;
399                 updateUi(empty);
400             }
401             if (mUpdateSuggestions) {
402                 if (mQueryListener != null) {
403                     mQueryListener.onQueryChanged();
404                 }
405             }
406         }
407 
408         @Override
beforeTextChanged(CharSequence s, int start, int count, int after)409         public void beforeTextChanged(CharSequence s, int start, int count, int after) {
410         }
411 
412         @Override
onTextChanged(CharSequence s, int start, int before, int count)413         public void onTextChanged(CharSequence s, int start, int before, int count) {
414         }
415     }
416 
417     /**
418      * Handles key events on the suggestions list view.
419      */
420     protected class SuggestionsViewKeyListener implements View.OnKeyListener {
421         @Override
onKey(View v, int keyCode, KeyEvent event)422         public boolean onKey(View v, int keyCode, KeyEvent event) {
423             if (event.getAction() == KeyEvent.ACTION_DOWN
424                     && v instanceof SuggestionsListView<?>) {
425                 SuggestionsListView<?> listView = (SuggestionsListView<?>) v;
426                 if (onSuggestionKeyDown(listView.getSuggestionsAdapter(),
427                         listView.getSelectedItemId(), keyCode, event)) {
428                     return true;
429                 }
430             }
431             return forwardKeyToQueryTextView(keyCode, event);
432         }
433     }
434 
435     private class InputMethodCloser implements SuggestionsView.OnScrollListener {
436 
437         @Override
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)438         public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
439                 int totalItemCount) {
440         }
441 
442         @Override
onScrollStateChanged(AbsListView view, int scrollState)443         public void onScrollStateChanged(AbsListView view, int scrollState) {
444             considerHidingInputMethod();
445         }
446     }
447 
448     /**
449      * Listens for clicks on the source selector.
450      */
451     private class SearchGoButtonClickListener implements View.OnClickListener {
452         @Override
onClick(View view)453         public void onClick(View view) {
454             onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
455         }
456     }
457 
458     /**
459      * This class handles enter key presses in the query text view.
460      */
461     private class QueryTextEditorActionListener implements OnEditorActionListener {
462         @Override
onEditorAction(TextView v, int actionId, KeyEvent event)463         public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
464             boolean consumed = false;
465             if (event != null) {
466                 if (event.getAction() == KeyEvent.ACTION_UP) {
467                     consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
468                 } else if (event.getAction() == KeyEvent.ACTION_DOWN) {
469                     // we have to consume the down event so that we receive the up event too
470                     consumed = true;
471                 }
472             }
473             if (DBG) Log.d(TAG, "onEditorAction consumed=" + consumed);
474             return consumed;
475         }
476     }
477 
478     /**
479      * Handles key events on the search and voice search buttons,
480      * by refocusing to EditText.
481      */
482     private class ButtonsKeyListener implements View.OnKeyListener {
483         @Override
onKey(View v, int keyCode, KeyEvent event)484         public boolean onKey(View v, int keyCode, KeyEvent event) {
485             return forwardKeyToQueryTextView(keyCode, event);
486         }
487     }
488 
forwardKeyToQueryTextView(int keyCode, KeyEvent event)489     private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
490         if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) {
491             if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
492             if (mQueryTextView.requestFocus()) {
493                 return mQueryTextView.dispatchKeyEvent(event);
494             }
495         }
496         return false;
497     }
498 
shouldForwardToQueryTextView(int keyCode)499     private boolean shouldForwardToQueryTextView(int keyCode) {
500         switch (keyCode) {
501             case KeyEvent.KEYCODE_DPAD_UP:
502             case KeyEvent.KEYCODE_DPAD_DOWN:
503             case KeyEvent.KEYCODE_DPAD_LEFT:
504             case KeyEvent.KEYCODE_DPAD_RIGHT:
505             case KeyEvent.KEYCODE_DPAD_CENTER:
506             case KeyEvent.KEYCODE_ENTER:
507             case KeyEvent.KEYCODE_SEARCH:
508                 return false;
509             default:
510                 return true;
511         }
512     }
513 
514     /**
515      * Hides the input method when the suggestions get focus.
516      */
517     private class SuggestListFocusListener implements OnFocusChangeListener {
518         @Override
onFocusChange(View v, boolean focused)519         public void onFocusChange(View v, boolean focused) {
520             if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
521             if (focused) {
522                 considerHidingInputMethod();
523             }
524         }
525     }
526 
527     private class QueryTextViewFocusListener implements OnFocusChangeListener {
528         @Override
onFocusChange(View v, boolean focused)529         public void onFocusChange(View v, boolean focused) {
530             if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
531             if (focused) {
532                 // The query box got focus, show the input method
533                 showInputMethodForQuery();
534             }
535         }
536     }
537 
538     protected class SuggestionsObserver extends DataSetObserver {
539         @Override
onChanged()540         public void onChanged() {
541             onSuggestionsChanged();
542         }
543     }
544 
545     public interface QueryListener {
onQueryChanged()546         void onQueryChanged();
547     }
548 
549     public interface SearchClickListener {
onSearchClicked(int method)550         boolean onSearchClicked(int method);
551     }
552 
553     private class CloseClickListener implements OnClickListener {
554         @Override
onClick(View v)555         public void onClick(View v) {
556             if (!isQueryEmpty()) {
557                 mQueryTextView.setText("");
558             } else {
559                 mExitClickListener.onClick(v);
560             }
561         }
562     }
563 }
564