SearchActivityView.java revision 04780ccd1ef55fca5234cdad4386d7248bb9b3dd
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.content.Context;
32import android.database.DataSetObserver;
33import android.graphics.drawable.Drawable;
34import android.text.Editable;
35import android.text.TextUtils;
36import android.text.TextWatcher;
37import android.util.AttributeSet;
38import android.util.Log;
39import android.view.KeyEvent;
40import android.view.View;
41import android.view.inputmethod.CompletionInfo;
42import android.view.inputmethod.InputMethodManager;
43import android.widget.AbsListView;
44import android.widget.ImageButton;
45import android.widget.ListAdapter;
46import android.widget.RelativeLayout;
47
48import java.util.ArrayList;
49import java.util.Arrays;
50
51/**
52 *
53 */
54public abstract class SearchActivityView extends RelativeLayout {
55    protected static final boolean DBG = false;
56    protected static final String TAG = "QSB.SearchActivityView";
57
58    // The string used for privateImeOptions to identify to the IME that it should not show
59    // a microphone button since one already exists in the search dialog.
60    // TODO: This should move to android-common or something.
61    private static final String IME_OPTION_NO_MICROPHONE = "nm";
62
63    private Corpus mCorpus;
64
65    protected QueryTextView mQueryTextView;
66    // True if the query was empty on the previous call to updateQuery()
67    protected boolean mQueryWasEmpty = true;
68    protected Drawable mQueryTextEmptyBg;
69    protected Drawable mQueryTextNotEmptyBg;
70
71    protected SuggestionsListView<ListAdapter> mSuggestionsView;
72    protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter;
73
74    protected ImageButton mSearchCloseButton;
75    protected ImageButton mSearchGoButton;
76    protected ImageButton mVoiceSearchButton;
77
78    protected ButtonsKeyListener mButtonsKeyListener;
79
80    private boolean mUpdateSuggestions;
81
82    private QueryListener mQueryListener;
83    private SearchClickListener mSearchClickListener;
84    private View.OnClickListener mExitClickListener;
85
86    public SearchActivityView(Context context) {
87        super(context);
88    }
89
90    public SearchActivityView(Context context, AttributeSet attrs) {
91        super(context, attrs);
92    }
93
94    public SearchActivityView(Context context, AttributeSet attrs, int defStyle) {
95        super(context, attrs, defStyle);
96    }
97
98    @Override
99    protected void onFinishInflate() {
100        mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text);
101
102        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
103        mSuggestionsView.setOnScrollListener(new InputMethodCloser());
104        mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
105        mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
106
107        mSuggestionsAdapter = createSuggestionsAdapter();
108        // TODO: why do we need focus listeners both on the SuggestionsView and the individual
109        // suggestions?
110        mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener());
111
112        mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn);
113        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
114        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
115
116        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
117        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
118        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
119        mQueryTextEmptyBg = mQueryTextView.getBackground();
120
121        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
122
123        mButtonsKeyListener = new ButtonsKeyListener();
124        mSearchGoButton.setOnKeyListener(mButtonsKeyListener);
125        mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener);
126        if (mSearchCloseButton != null) {
127            mSearchCloseButton.setOnKeyListener(mButtonsKeyListener);
128            mSearchCloseButton.setOnClickListener(new CloseClickListener());
129        }
130
131        mUpdateSuggestions = true;
132    }
133
134    public abstract void onResume();
135
136    public abstract void onStop();
137
138    public void onPause() {
139        // Override if necessary
140    }
141
142    public void start() {
143        mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver());
144        mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter);
145    }
146
147    public void destroy() {
148        mSuggestionsView.setSuggestionsAdapter(null);  // closes mSuggestionsAdapter
149    }
150
151    // TODO: Get rid of this. To make it more easily testable,
152    // the SearchActivityView should not depend on QsbApplication.
153    protected QsbApplication getQsbApplication() {
154        return QsbApplication.get(getContext());
155    }
156
157    private VoiceSearch getVoiceSearch() {
158        return getQsbApplication().getVoiceSearch();
159    }
160
161    protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() {
162        return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter(
163                getQsbApplication().getSuggestionViewFactory()));
164    }
165
166    protected Corpora getCorpora() {
167        return getQsbApplication().getCorpora();
168    }
169
170    public Corpus getCorpus() {
171        return mCorpus;
172    }
173
174    protected abstract Promoter createSuggestionsPromoter();
175
176    protected Corpus getCorpus(String sourceName) {
177        if (sourceName == null) return null;
178        Corpus corpus = getCorpora().getCorpus(sourceName);
179        if (corpus == null) {
180            Log.w(TAG, "Unknown corpus " + sourceName);
181            return null;
182        }
183        return corpus;
184    }
185
186    public void onCorpusSelected(String corpusName) {
187        setCorpus(corpusName);
188        focusQueryTextView();
189        showInputMethodForQuery();
190    }
191
192    public void setCorpus(String corpusName) {
193        if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")");
194        Corpus corpus = getCorpus(corpusName);
195        setCorpus(corpus);
196        updateUi();
197    }
198
199    protected void setCorpus(Corpus corpus) {
200        mCorpus = corpus;
201        mSuggestionsAdapter.setPromoter(createSuggestionsPromoter());
202        Suggestions suggestions = getSuggestions();
203        if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) {
204            getActivity().updateSuggestions();
205        }
206    }
207
208    public String getCorpusName() {
209        Corpus corpus = getCorpus();
210        return corpus == null ? null : corpus.getName();
211    }
212
213    public abstract Corpus getSearchCorpus();
214
215    public Corpus getWebCorpus() {
216        Corpus webCorpus = getCorpora().getWebCorpus();
217        if (webCorpus == null) {
218            Log.e(TAG, "No web corpus");
219        }
220        return webCorpus;
221    }
222
223    public void setMaxPromotedSuggestions(int maxPromoted) {
224        mSuggestionsView.setLimitSuggestionsToViewHeight(false);
225        mSuggestionsAdapter.setMaxPromoted(maxPromoted);
226    }
227
228    public void limitSuggestionsToViewHeight() {
229        mSuggestionsView.setLimitSuggestionsToViewHeight(true);
230    }
231
232    public void setMaxPromotedResults(int maxPromoted) {
233    }
234
235    public void limitResultsToViewHeight() {
236    }
237
238    public void setQueryListener(QueryListener listener) {
239        mQueryListener = listener;
240    }
241
242    public void setSearchClickListener(SearchClickListener listener) {
243        mSearchClickListener = listener;
244    }
245
246    public abstract void showCorpusSelectionDialog();
247
248    public void setVoiceSearchButtonClickListener(View.OnClickListener listener) {
249        if (mVoiceSearchButton != null) {
250            mVoiceSearchButton.setOnClickListener(listener);
251        }
252    }
253
254    public void setSuggestionClickListener(final SuggestionClickListener listener) {
255        mSuggestionsAdapter.setSuggestionClickListener(listener);
256        mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() {
257            @Override
258            public void onCommitCompletion(int position) {
259                mSuggestionsAdapter.onSuggestionClicked(position);
260            }
261        });
262    }
263
264    public void setExitClickListener(final View.OnClickListener listener) {
265        mExitClickListener = 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     * If the input method is in fullscreen mode, and the selector corpus
392     * is All or Web, use the web search suggestions as completions.
393     */
394    protected void updateInputMethodSuggestions() {
395        InputMethodManager imm = (InputMethodManager)
396                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
397        if (imm == null || !imm.isFullscreenMode()) return;
398        Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
399        if (suggestions == null) return;
400        CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
401        if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
402        imm.displayCompletions(mQueryTextView, completions);
403    }
404
405    private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
406        // TODO: This should also include include web search shortcuts
407        CorpusResult cursor = suggestions.getWebResult();
408        if (cursor == null) return null;
409        int count = cursor.getCount();
410        ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
411        boolean usingWebCorpus = isSearchCorpusWeb();
412        for (int i = 0; i < count; i++) {
413            cursor.moveTo(i);
414            if (!usingWebCorpus || cursor.isWebSearchSuggestion()) {
415                String text1 = cursor.getSuggestionText1();
416                completions.add(new CompletionInfo(i, i, text1));
417            }
418        }
419        return completions.toArray(new CompletionInfo[completions.size()]);
420    }
421
422    protected void onSuggestionsChanged() {
423        updateInputMethodSuggestions();
424    }
425
426    /**
427     * Checks if the corpus used for typed searches is the web corpus.
428     */
429    protected boolean isSearchCorpusWeb() {
430        Corpus corpus = getSearchCorpus();
431        return corpus != null && corpus.isWebCorpus();
432    }
433
434    protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter,
435            long suggestionId, int keyCode, KeyEvent event) {
436        // Treat enter or search as a click
437        if (       keyCode == KeyEvent.KEYCODE_ENTER
438                || keyCode == KeyEvent.KEYCODE_SEARCH
439                || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
440            if (adapter != null) {
441                adapter.onSuggestionClicked(suggestionId);
442                return true;
443            } else {
444                return false;
445            }
446        }
447
448        return false;
449    }
450
451    protected boolean onSearchClicked(int method) {
452        if (mSearchClickListener != null) {
453            return mSearchClickListener.onSearchClicked(method);
454        }
455        return false;
456    }
457
458    /**
459     * Filters the suggestions list when the search text changes.
460     */
461    private class SearchTextWatcher implements TextWatcher {
462        public void afterTextChanged(Editable s) {
463            boolean empty = s.length() == 0;
464            if (empty != mQueryWasEmpty) {
465                mQueryWasEmpty = empty;
466                updateUi(empty);
467            }
468            if (mUpdateSuggestions) {
469                if (mQueryListener != null) {
470                    mQueryListener.onQueryChanged();
471                }
472            }
473        }
474
475        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
476        }
477
478        public void onTextChanged(CharSequence s, int start, int before, int count) {
479        }
480    }
481
482    /**
483     * Handles key events on the suggestions list view.
484     */
485    protected class SuggestionsViewKeyListener implements View.OnKeyListener {
486        public boolean onKey(View v, int keyCode, KeyEvent event) {
487            if (event.getAction() == KeyEvent.ACTION_DOWN
488                    && v instanceof SuggestionsListView<?>) {
489                SuggestionsListView<?> listView = (SuggestionsListView<?>) v;
490                if (onSuggestionKeyDown(listView.getSuggestionsAdapter(),
491                        listView.getSelectedItemId(), keyCode, event)) {
492                    return true;
493                }
494            }
495            return forwardKeyToQueryTextView(keyCode, event);
496        }
497    }
498
499    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
500
501        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
502                int totalItemCount) {
503        }
504
505        public void onScrollStateChanged(AbsListView view, int scrollState) {
506            considerHidingInputMethod();
507        }
508    }
509
510    /**
511     * Listens for clicks on the source selector.
512     */
513    private class SearchGoButtonClickListener implements View.OnClickListener {
514        public void onClick(View view) {
515            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
516        }
517    }
518
519    /**
520     * Handles non-text keys in the query text view.
521     */
522    private class QueryTextViewKeyListener implements View.OnKeyListener {
523        public boolean onKey(View view, int keyCode, KeyEvent event) {
524            // Handle IME search action key
525            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
526                // if no action was taken, consume the key event so that the keyboard
527                // remains on screen.
528                return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
529            }
530            return false;
531        }
532    }
533
534    /**
535     * Handles key events on the search and voice search buttons,
536     * by refocusing to EditText.
537     */
538    private class ButtonsKeyListener implements View.OnKeyListener {
539        public boolean onKey(View v, int keyCode, KeyEvent event) {
540            return forwardKeyToQueryTextView(keyCode, event);
541        }
542    }
543
544    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
545        if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) {
546            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
547            if (mQueryTextView.requestFocus()) {
548                return mQueryTextView.dispatchKeyEvent(event);
549            }
550        }
551        return false;
552    }
553
554    private boolean shouldForwardToQueryTextView(int keyCode) {
555        switch (keyCode) {
556            case KeyEvent.KEYCODE_DPAD_UP:
557            case KeyEvent.KEYCODE_DPAD_DOWN:
558            case KeyEvent.KEYCODE_DPAD_LEFT:
559            case KeyEvent.KEYCODE_DPAD_RIGHT:
560            case KeyEvent.KEYCODE_DPAD_CENTER:
561            case KeyEvent.KEYCODE_ENTER:
562            case KeyEvent.KEYCODE_SEARCH:
563                return false;
564            default:
565                return true;
566        }
567    }
568
569    /**
570     * Hides the input method when the suggestions get focus.
571     */
572    private class SuggestListFocusListener implements OnFocusChangeListener {
573        public void onFocusChange(View v, boolean focused) {
574            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
575            if (focused) {
576                considerHidingInputMethod();
577            }
578        }
579    }
580
581    private class QueryTextViewFocusListener implements OnFocusChangeListener {
582        public void onFocusChange(View v, boolean focused) {
583            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
584            if (focused) {
585                // The query box got focus, show the input method
586                showInputMethodForQuery();
587            }
588        }
589    }
590
591    protected class SuggestionsObserver extends DataSetObserver {
592        @Override
593        public void onChanged() {
594            onSuggestionsChanged();
595        }
596    }
597
598    public interface QueryListener {
599        void onQueryChanged();
600    }
601
602    public interface SearchClickListener {
603        boolean onSearchClicked(int method);
604    }
605
606    private class CloseClickListener implements OnClickListener {
607
608        public void onClick(View v) {
609            if (!TextUtils.isEmpty(mQueryTextView.getText())) {
610                mQueryTextView.setText("");
611            } else {
612                mExitClickListener.onClick(v);
613            }
614        }
615    }
616}
617