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