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