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