SearchActivityView.java revision 55938341e72f1581d6caae51a31d8d72a1cd8138
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
17package com.android.quicksearchbox.ui;
18
19import com.android.quicksearchbox.Corpora;
20import com.android.quicksearchbox.Corpus;
21import com.android.quicksearchbox.CorpusResult;
22import com.android.quicksearchbox.Logger;
23import com.android.quicksearchbox.Promoter;
24import com.android.quicksearchbox.QsbApplication;
25import com.android.quicksearchbox.R;
26import com.android.quicksearchbox.SearchActivity;
27import com.android.quicksearchbox.SuggestionCursor;
28import com.android.quicksearchbox.Suggestions;
29import com.android.quicksearchbox.VoiceSearch;
30
31import android.app.Activity;
32import android.content.Context;
33import android.database.DataSetObserver;
34import android.graphics.drawable.Drawable;
35import android.text.Editable;
36import android.text.TextUtils;
37import android.text.TextWatcher;
38import android.util.AttributeSet;
39import android.util.Log;
40import android.view.KeyEvent;
41import android.view.View;
42import android.view.inputmethod.CompletionInfo;
43import android.view.inputmethod.InputMethodManager;
44import android.widget.AbsListView;
45import android.widget.ImageButton;
46import android.widget.ListAdapter;
47import android.widget.RelativeLayout;
48
49import java.util.ArrayList;
50import java.util.Arrays;
51
52/**
53 *
54 */
55public abstract class SearchActivityView extends RelativeLayout {
56    protected static final boolean DBG = false;
57    protected static final String TAG = "QSB.SearchActivityView";
58
59    // The string used for privateImeOptions to identify to the IME that it should not show
60    // a microphone button since one already exists in the search dialog.
61    // TODO: This should move to android-common or something.
62    private static final String IME_OPTION_NO_MICROPHONE = "nm";
63
64    private Corpus mCorpus;
65
66    protected QueryTextView mQueryTextView;
67    // True if the query was empty on the previous call to updateQuery()
68    protected boolean mQueryWasEmpty = true;
69    protected Drawable mQueryTextEmptyBg;
70    protected Drawable mQueryTextNotEmptyBg;
71
72    protected SuggestionsListView<ListAdapter> mSuggestionsView;
73    protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter;
74
75    protected ImageButton mSearchCloseButton;
76    protected ImageButton mSearchGoButton;
77    protected ImageButton mVoiceSearchButton;
78
79    protected ButtonsKeyListener mButtonsKeyListener;
80
81    private boolean mUpdateSuggestions;
82
83    private QueryListener mQueryListener;
84    private SearchClickListener mSearchClickListener;
85    private View.OnClickListener mExitClickListener;
86
87    public SearchActivityView(Context context) {
88        super(context);
89    }
90
91    public SearchActivityView(Context context, AttributeSet attrs) {
92        super(context, attrs);
93    }
94
95    public SearchActivityView(Context context, AttributeSet attrs, int defStyle) {
96        super(context, attrs, defStyle);
97    }
98
99    @Override
100    protected void onFinishInflate() {
101        mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text);
102
103        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
104        mSuggestionsView.setOnScrollListener(new InputMethodCloser());
105        mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
106        mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
107
108        mSuggestionsAdapter = createSuggestionsAdapter();
109        // TODO: why do we need focus listeners both on the SuggestionsView and the individual
110        // suggestions?
111        mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener());
112
113        mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn);
114        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
115        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
116
117        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
118        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
119        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
120        mQueryTextEmptyBg = mQueryTextView.getBackground();
121
122        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
123
124        mButtonsKeyListener = new ButtonsKeyListener();
125        mSearchGoButton.setOnKeyListener(mButtonsKeyListener);
126        mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener);
127        if (mSearchCloseButton != null) {
128            mSearchCloseButton.setOnKeyListener(mButtonsKeyListener);
129            mSearchCloseButton.setOnClickListener(new CloseClickListener());
130        }
131
132        mUpdateSuggestions = true;
133    }
134
135    public abstract void onResume();
136
137    public abstract void onStop();
138
139    public void start() {
140        mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver());
141        mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter);
142    }
143
144    public void destroy() {
145        mSuggestionsView.setSuggestionsAdapter(null);  // closes mSuggestionsAdapter
146    }
147
148    // TODO: Get rid of this. To make it more easily testable,
149    // the SearchActivityView should not depend on QsbApplication.
150    protected QsbApplication getQsbApplication() {
151        return QsbApplication.get(getContext());
152    }
153
154    private VoiceSearch getVoiceSearch() {
155        return getQsbApplication().getVoiceSearch();
156    }
157
158    protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() {
159        return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter(
160                getQsbApplication().getSuggestionViewFactory()));
161    }
162
163    protected Corpora getCorpora() {
164        return getQsbApplication().getCorpora();
165    }
166
167    public Corpus getCorpus() {
168        return mCorpus;
169    }
170
171    protected abstract Promoter createSuggestionsPromoter();
172
173    protected Corpus getCorpus(String sourceName) {
174        if (sourceName == null) return null;
175        Corpus corpus = getCorpora().getCorpus(sourceName);
176        if (corpus == null) {
177            Log.w(TAG, "Unknown corpus " + sourceName);
178            return null;
179        }
180        return corpus;
181    }
182
183    public void onCorpusSelected(String corpusName) {
184        setCorpus(corpusName);
185        focusQueryTextView();
186        showInputMethodForQuery();
187    }
188
189    public void setCorpus(String corpusName) {
190        if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")");
191        Corpus corpus = getCorpus(corpusName);
192        setCorpus(corpus);
193        updateUi();
194    }
195
196    protected void setCorpus(Corpus corpus) {
197        mCorpus = corpus;
198        mSuggestionsAdapter.setPromoter(createSuggestionsPromoter());
199        Suggestions suggestions = getSuggestions();
200        if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) {
201            getActivity().updateSuggestions();
202        }
203    }
204
205    public String getCorpusName() {
206        Corpus corpus = getCorpus();
207        return corpus == null ? null : corpus.getName();
208    }
209
210    public abstract Corpus getSearchCorpus();
211
212    public Corpus getWebCorpus() {
213        Corpus webCorpus = getCorpora().getWebCorpus();
214        if (webCorpus == null) {
215            Log.e(TAG, "No web corpus");
216        }
217        return webCorpus;
218    }
219
220    public void setMaxPromotedSuggestions(int maxPromoted) {
221        mSuggestionsView.setLimitSuggestionsToViewHeight(false);
222        mSuggestionsAdapter.setMaxPromoted(maxPromoted);
223    }
224
225    public void limitSuggestionsToViewHeight() {
226        mSuggestionsView.setLimitSuggestionsToViewHeight(true);
227    }
228
229    public void setMaxPromotedResults(int maxPromoted) {
230    }
231
232    public void limitResultsToViewHeight() {
233    }
234
235    public void setQueryListener(QueryListener listener) {
236        mQueryListener = listener;
237    }
238
239    public void setSearchClickListener(SearchClickListener listener) {
240        mSearchClickListener = listener;
241    }
242
243    public abstract void showCorpusSelectionDialog();
244
245    public void setVoiceSearchButtonClickListener(View.OnClickListener listener) {
246        if (mVoiceSearchButton != null) {
247            mVoiceSearchButton.setOnClickListener(listener);
248        }
249    }
250
251    public void setSuggestionClickListener(final SuggestionClickListener listener) {
252        mSuggestionsAdapter.setSuggestionClickListener(listener);
253        mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() {
254            @Override
255            public void onCommitCompletion(int position) {
256                mSuggestionsAdapter.onSuggestionClicked(position);
257            }
258        });
259    }
260
261    public void setExitClickListener(final View.OnClickListener listener) {
262        mExitClickListener = listener;
263    }
264
265    public void setEmptySpaceClickListener(final View.OnClickListener listener) {
266    }
267
268    public Suggestions getSuggestions() {
269        return mSuggestionsAdapter.getSuggestions();
270    }
271
272    public SuggestionCursor getCurrentPromotedSuggestions() {
273        return mSuggestionsAdapter.getCurrentPromotedSuggestions();
274    }
275
276    public void setSuggestions(Suggestions suggestions) {
277        suggestions.acquire();
278        mSuggestionsAdapter.setSuggestions(suggestions);
279    }
280
281    public void clearSuggestions() {
282        mSuggestionsAdapter.setSuggestions(null);
283    }
284
285    public String getQuery() {
286        CharSequence q = mQueryTextView.getText();
287        return q == null ? "" : q.toString();
288    }
289
290    /**
291     * Sets the text in the query box. Does not update the suggestions.
292     */
293    public void setQuery(String query, boolean selectAll) {
294        mUpdateSuggestions = false;
295        mQueryTextView.setText(query);
296        mQueryTextView.setTextSelection(selectAll);
297        mUpdateSuggestions = true;
298    }
299
300    protected SearchActivity getActivity() {
301        Context context = getContext();
302        if (context instanceof SearchActivity) {
303            return (SearchActivity) context;
304        } else {
305            return null;
306        }
307    }
308
309    public void hideSuggestions() {
310        mSuggestionsView.setVisibility(GONE);
311    }
312
313    public void showSuggestions() {
314        mSuggestionsView.setVisibility(VISIBLE);
315    }
316
317    public void focusQueryTextView() {
318        mQueryTextView.requestFocus();
319    }
320
321    protected void updateUi() {
322        updateUi(getQuery().length() == 0);
323    }
324
325    protected void updateUi(boolean queryEmpty) {
326        updateQueryTextView(queryEmpty);
327        updateSearchGoButton(queryEmpty);
328        updateVoiceSearchButton(queryEmpty);
329    }
330
331    protected void updateQueryTextView(boolean queryEmpty) {
332        if (queryEmpty) {
333            if (isSearchCorpusWeb()) {
334                mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg);
335                mQueryTextView.setHint(null);
336            } else {
337                if (mQueryTextNotEmptyBg == null) {
338                    mQueryTextNotEmptyBg =
339                            getResources().getDrawable(R.drawable.textfield_search_empty);
340                }
341                mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg);
342                Corpus corpus = getCorpus();
343                mQueryTextView.setHint(corpus == null ? "" : corpus.getHint());
344            }
345        } else {
346            mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
347        }
348    }
349
350    private void updateSearchGoButton(boolean queryEmpty) {
351        if (queryEmpty) {
352            mSearchGoButton.setVisibility(View.GONE);
353        } else {
354            mSearchGoButton.setVisibility(View.VISIBLE);
355        }
356    }
357
358    protected void updateVoiceSearchButton(boolean queryEmpty) {
359        if (shouldShowVoiceSearch(queryEmpty)
360                && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) {
361            mVoiceSearchButton.setVisibility(View.VISIBLE);
362            mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
363        } else {
364            mVoiceSearchButton.setVisibility(View.GONE);
365            mQueryTextView.setPrivateImeOptions(null);
366        }
367    }
368
369    protected boolean shouldShowVoiceSearch(boolean queryEmpty) {
370        return queryEmpty;
371    }
372
373    /**
374     * Hides the input method.
375     */
376    protected void hideInputMethod() {
377        InputMethodManager imm = (InputMethodManager)
378                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
379        if (imm != null) {
380            imm.hideSoftInputFromWindow(getWindowToken(), 0);
381        }
382    }
383
384    public abstract void considerHidingInputMethod();
385
386    public void showInputMethodForQuery() {
387        mQueryTextView.showInputMethod();
388    }
389
390    /**
391     * Overrides the handling of the back key to dismiss the activity.
392     */
393    @Override
394    public boolean dispatchKeyEventPreIme(KeyEvent event) {
395        Activity activity = getActivity();
396        if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
397            KeyEvent.DispatcherState state = getKeyDispatcherState();
398            if (state != null) {
399                if (event.getAction() == KeyEvent.ACTION_DOWN
400                        && event.getRepeatCount() == 0) {
401                    state.startTracking(event, this);
402                    return true;
403                } else if (event.getAction() == KeyEvent.ACTION_UP
404                        && !event.isCanceled() && state.isTracking(event)) {
405                    hideInputMethod();
406                    activity.onBackPressed();
407                    return true;
408                }
409            }
410        }
411        return super.dispatchKeyEventPreIme(event);
412    }
413
414    /**
415     * If the input method is in fullscreen mode, and the selector corpus
416     * is All or Web, use the web search suggestions as completions.
417     */
418    protected void updateInputMethodSuggestions() {
419        InputMethodManager imm = (InputMethodManager)
420                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
421        if (imm == null || !imm.isFullscreenMode()) return;
422        Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
423        if (suggestions == null) return;
424        CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
425        if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
426        imm.displayCompletions(mQueryTextView, completions);
427    }
428
429    private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
430        // TODO: This should also include include web search shortcuts
431        CorpusResult cursor = suggestions.getWebResult();
432        if (cursor == null) return null;
433        int count = cursor.getCount();
434        ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
435        boolean usingWebCorpus = isSearchCorpusWeb();
436        for (int i = 0; i < count; i++) {
437            cursor.moveTo(i);
438            if (!usingWebCorpus || cursor.isWebSearchSuggestion()) {
439                String text1 = cursor.getSuggestionText1();
440                completions.add(new CompletionInfo(i, i, text1));
441            }
442        }
443        return completions.toArray(new CompletionInfo[completions.size()]);
444    }
445
446    protected void onSuggestionsChanged() {
447        updateInputMethodSuggestions();
448    }
449
450    /**
451     * Checks if the corpus used for typed searches is the web corpus.
452     */
453    protected boolean isSearchCorpusWeb() {
454        Corpus corpus = getSearchCorpus();
455        return corpus != null && corpus.isWebCorpus();
456    }
457
458    protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter,
459            int position, int keyCode, KeyEvent event) {
460        // Treat enter or search as a click
461        if (       keyCode == KeyEvent.KEYCODE_ENTER
462                || keyCode == KeyEvent.KEYCODE_SEARCH
463                || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
464            if (adapter != null) {
465                SuggestionsAdapter<?> suggestionsAdapter = adapter;
466                suggestionsAdapter.onSuggestionClicked(position);
467                return true;
468            } else {
469                return false;
470            }
471        }
472
473        return false;
474    }
475
476    protected boolean onSearchClicked(int method) {
477        if (mSearchClickListener != null) {
478            return mSearchClickListener.onSearchClicked(method);
479        }
480        return false;
481    }
482
483    /**
484     * Filters the suggestions list when the search text changes.
485     */
486    private class SearchTextWatcher implements TextWatcher {
487        public void afterTextChanged(Editable s) {
488            boolean empty = s.length() == 0;
489            if (empty != mQueryWasEmpty) {
490                mQueryWasEmpty = empty;
491                updateUi(empty);
492            }
493            if (mUpdateSuggestions) {
494                if (mQueryListener != null) {
495                    mQueryListener.onQueryChanged();
496                }
497            }
498        }
499
500        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
501        }
502
503        public void onTextChanged(CharSequence s, int start, int before, int count) {
504        }
505    }
506
507    /**
508     * Handles key events on the suggestions list view.
509     */
510    protected class SuggestionsViewKeyListener implements View.OnKeyListener {
511        public boolean onKey(View v, int keyCode, KeyEvent event) {
512            if (event.getAction() == KeyEvent.ACTION_DOWN
513                    && v instanceof SuggestionsView) {
514                SuggestionsView view = ((SuggestionsView) v);
515                int position = view.getSelectedPosition();
516                if (onSuggestionKeyDown(view.getSuggestionsAdapter(), position, keyCode, event)) {
517                    return true;
518                }
519            }
520            return forwardKeyToQueryTextView(keyCode, event);
521        }
522    }
523
524    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
525
526        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
527                int totalItemCount) {
528        }
529
530        public void onScrollStateChanged(AbsListView view, int scrollState) {
531            considerHidingInputMethod();
532        }
533    }
534
535    /**
536     * Listens for clicks on the source selector.
537     */
538    private class SearchGoButtonClickListener implements View.OnClickListener {
539        public void onClick(View view) {
540            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
541        }
542    }
543
544    /**
545     * Handles non-text keys in the query text view.
546     */
547    private class QueryTextViewKeyListener implements View.OnKeyListener {
548        public boolean onKey(View view, int keyCode, KeyEvent event) {
549            // Handle IME search action key
550            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
551                // if no action was taken, consume the key event so that the keyboard
552                // remains on screen.
553                return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
554            }
555            return false;
556        }
557    }
558
559    /**
560     * Handles key events on the search and voice search buttons,
561     * by refocusing to EditText.
562     */
563    private class ButtonsKeyListener implements View.OnKeyListener {
564        public boolean onKey(View v, int keyCode, KeyEvent event) {
565            return forwardKeyToQueryTextView(keyCode, event);
566        }
567    }
568
569    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
570        if (!event.isSystem() && !isDpadKey(keyCode)) {
571            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
572            if (mQueryTextView.requestFocus()) {
573                return mQueryTextView.dispatchKeyEvent(event);
574            }
575        }
576        return false;
577    }
578
579    private boolean isDpadKey(int keyCode) {
580        switch (keyCode) {
581            case KeyEvent.KEYCODE_DPAD_UP:
582            case KeyEvent.KEYCODE_DPAD_DOWN:
583            case KeyEvent.KEYCODE_DPAD_LEFT:
584            case KeyEvent.KEYCODE_DPAD_RIGHT:
585            case KeyEvent.KEYCODE_DPAD_CENTER:
586                return true;
587            default:
588                return false;
589        }
590    }
591
592    /**
593     * Hides the input method when the suggestions get focus.
594     */
595    private class SuggestListFocusListener implements OnFocusChangeListener {
596        public void onFocusChange(View v, boolean focused) {
597            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
598            if (focused) {
599                considerHidingInputMethod();
600            }
601        }
602    }
603
604    private class QueryTextViewFocusListener implements OnFocusChangeListener {
605        public void onFocusChange(View v, boolean focused) {
606            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
607            if (focused) {
608                // The query box got focus, show the input method
609                showInputMethodForQuery();
610            }
611        }
612    }
613
614    protected class SuggestionsObserver extends DataSetObserver {
615        @Override
616        public void onChanged() {
617            onSuggestionsChanged();
618        }
619    }
620
621    public interface QueryListener {
622        void onQueryChanged();
623    }
624
625    public interface SearchClickListener {
626        boolean onSearchClicked(int method);
627    }
628
629    private class CloseClickListener implements OnClickListener {
630
631        public void onClick(View v) {
632            if (!TextUtils.isEmpty(mQueryTextView.getText())) {
633                mQueryTextView.setText("");
634            } else {
635                mExitClickListener.onClick(v);
636            }
637        }
638    }
639}
640