SearchActivityView.java revision 7a0c3a7c6fdabce949b59e0a2c6ec1d44a140c24
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.ListAdapter;
48import android.widget.RelativeLayout;
49
50import java.util.ArrayList;
51import java.util.Arrays;
52
53/**
54 *
55 */
56public abstract class SearchActivityView extends RelativeLayout
57        implements SuggestionsAdapterChangeListener {
58    protected static final boolean DBG = false;
59    protected static final String TAG = "QSB.SearchActivityView";
60
61    // The string used for privateImeOptions to identify to the IME that it should not show
62    // a microphone button since one already exists in the search dialog.
63    // TODO: This should move to android-common or something.
64    private static final String IME_OPTION_NO_MICROPHONE = "nm";
65
66    private Corpus mCorpus;
67
68    protected QueryTextView mQueryTextView;
69    // True if the query was empty on the previous call to updateQuery()
70    protected boolean mQueryWasEmpty = true;
71    protected Drawable mQueryTextEmptyBg;
72    protected Drawable mQueryTextNotEmptyBg;
73
74    protected SuggestionsListView<ListAdapter> mSuggestionsView;
75    protected SuggestionsAdapter<ListAdapter> 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.setSuggestionsAdapter(mSuggestionsAdapter);
140    }
141
142    public abstract void onResume();
143
144    public abstract void onStop();
145
146    public void start() {
147        mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver());
148        mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter);
149    }
150
151    public void destroy() {
152        mSuggestionsAdapter.setSuggestionAdapterChangeListener(null);
153        mSuggestionsView.setSuggestionsAdapter(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<ListAdapter> createSuggestionsAdapter() {
167        return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter(
168                getQsbApplication().getDefaultSuggestionViewFactory(),
169                getQsbApplication().getCorpora()));
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        Suggestions suggestions = getSuggestions();
209        if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) {
210            getActivity().updateSuggestions();
211        }
212    }
213
214    public String getCorpusName() {
215        Corpus corpus = getCorpus();
216        return corpus == null ? null : corpus.getName();
217    }
218
219    public abstract Corpus getSearchCorpus();
220
221    public Corpus getWebCorpus() {
222        Corpus webCorpus = getCorpora().getWebCorpus();
223        if (webCorpus == null) {
224            Log.e(TAG, "No web corpus");
225        }
226        return webCorpus;
227    }
228
229    public void setMaxPromotedSuggestions(int maxPromoted) {
230        mSuggestionsView.setLimitSuggestionsToViewHeight(false);
231        mSuggestionsAdapter.setMaxPromoted(maxPromoted);
232    }
233
234    public void limitSuggestionsToViewHeight() {
235        mSuggestionsView.setLimitSuggestionsToViewHeight(true);
236    }
237
238    public void setMaxPromotedResults(int maxPromoted) {
239    }
240
241    public void limitResultsToViewHeight() {
242    }
243
244    public void setQueryListener(QueryListener listener) {
245        mQueryListener = listener;
246    }
247
248    public void setSearchClickListener(SearchClickListener listener) {
249        mSearchClickListener = listener;
250    }
251
252    public abstract void showCorpusSelectionDialog();
253
254    public abstract void setSettingsButtonClickListener(View.OnClickListener listener);
255
256    public void setVoiceSearchButtonClickListener(View.OnClickListener listener) {
257        if (mVoiceSearchButton != null) {
258            mVoiceSearchButton.setOnClickListener(listener);
259        }
260    }
261
262    public void setSuggestionClickListener(final SuggestionClickListener listener) {
263        mSuggestionsAdapter.setSuggestionClickListener(listener);
264        mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() {
265            @Override
266            public void onCommitCompletion(int position) {
267                mSuggestionsAdapter.onSuggestionClicked(position);
268            }
269        });
270    }
271
272    public void setExitClickListener(final View.OnClickListener listener) {
273        mExitClickListener = listener;
274    }
275
276    public void setEmptySpaceClickListener(final View.OnClickListener listener) {
277    }
278
279    public Suggestions getSuggestions() {
280        return mSuggestionsAdapter.getSuggestions();
281    }
282
283    public SuggestionCursor getCurrentPromotedSuggestions() {
284        return mSuggestionsAdapter.getCurrentPromotedSuggestions();
285    }
286
287    public void setSuggestions(Suggestions suggestions) {
288        suggestions.acquire();
289        mSuggestionsAdapter.setSuggestions(suggestions);
290    }
291
292    public void clearSuggestions() {
293        mSuggestionsAdapter.setSuggestions(null);
294    }
295
296    public String getQuery() {
297        CharSequence q = mQueryTextView.getText();
298        return q == null ? "" : q.toString();
299    }
300
301    /**
302     * Sets the text in the query box. Does not update the suggestions.
303     */
304    public void setQuery(String query, boolean selectAll) {
305        mUpdateSuggestions = false;
306        mQueryTextView.setText(query);
307        mQueryTextView.setTextSelection(selectAll);
308        mUpdateSuggestions = true;
309    }
310
311    protected SearchActivity getActivity() {
312        Context context = getContext();
313        if (context instanceof SearchActivity) {
314            return (SearchActivity) context;
315        } else {
316            return null;
317        }
318    }
319
320    public void hideSuggestions() {
321        mSuggestionsView.setVisibility(GONE);
322    }
323
324    public void showSuggestions() {
325        mSuggestionsView.setVisibility(VISIBLE);
326    }
327
328    public void focusQueryTextView() {
329        mQueryTextView.requestFocus();
330    }
331
332    protected void updateUi() {
333        updateUi(getQuery().length() == 0);
334    }
335
336    protected void updateUi(boolean queryEmpty) {
337        updateQueryTextView(queryEmpty);
338        updateSearchGoButton(queryEmpty);
339        updateVoiceSearchButton(queryEmpty);
340    }
341
342    private void updateQueryTextView(boolean queryEmpty) {
343        if (queryEmpty) {
344            if (isSearchCorpusWeb()) {
345                mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg);
346                mQueryTextView.setHint(null);
347            } else {
348                if (mQueryTextNotEmptyBg == null) {
349                    mQueryTextNotEmptyBg =
350                            getResources().getDrawable(R.drawable.textfield_search_empty);
351                }
352                mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg);
353                Corpus corpus = getCorpus();
354                mQueryTextView.setHint(corpus == null ? "" : corpus.getHint());
355            }
356        } else {
357            mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
358        }
359    }
360
361    private void updateSearchGoButton(boolean queryEmpty) {
362        if (queryEmpty) {
363            mSearchGoButton.setVisibility(View.GONE);
364        } else {
365            mSearchGoButton.setVisibility(View.VISIBLE);
366        }
367    }
368
369    protected void updateVoiceSearchButton(boolean queryEmpty) {
370        if (shouldShowVoiceSearch(queryEmpty)
371                && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) {
372            mVoiceSearchButton.setVisibility(View.VISIBLE);
373            mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
374        } else {
375            mVoiceSearchButton.setVisibility(View.GONE);
376            mQueryTextView.setPrivateImeOptions(null);
377        }
378    }
379
380    protected boolean shouldShowVoiceSearch(boolean queryEmpty) {
381        return queryEmpty;
382    }
383
384    /**
385     * Hides the input method.
386     */
387    protected void hideInputMethod() {
388        InputMethodManager imm = (InputMethodManager)
389                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
390        if (imm != null) {
391            imm.hideSoftInputFromWindow(getWindowToken(), 0);
392        }
393    }
394
395    public abstract void considerHidingInputMethod();
396
397    public void showInputMethodForQuery() {
398        mQueryTextView.showInputMethod();
399    }
400
401    /**
402     * Overrides the handling of the back key to dismiss the activity.
403     */
404    @Override
405    public boolean dispatchKeyEventPreIme(KeyEvent event) {
406        Activity activity = getActivity();
407        if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
408            KeyEvent.DispatcherState state = getKeyDispatcherState();
409            if (state != null) {
410                if (event.getAction() == KeyEvent.ACTION_DOWN
411                        && event.getRepeatCount() == 0) {
412                    state.startTracking(event, this);
413                    return true;
414                } else if (event.getAction() == KeyEvent.ACTION_UP
415                        && !event.isCanceled() && state.isTracking(event)) {
416                    hideInputMethod();
417                    activity.onBackPressed();
418                    return true;
419                }
420            }
421        }
422        return super.dispatchKeyEventPreIme(event);
423    }
424
425    /**
426     * If the input method is in fullscreen mode, and the selector corpus
427     * is All or Web, use the web search suggestions as completions.
428     */
429    protected void updateInputMethodSuggestions() {
430        InputMethodManager imm = (InputMethodManager)
431                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
432        if (imm == null || !imm.isFullscreenMode()) return;
433        Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
434        if (suggestions == null) return;
435        CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
436        if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
437        imm.displayCompletions(mQueryTextView, completions);
438    }
439
440    private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
441        // TODO: This should also include include web search shortcuts
442        CorpusResult cursor = suggestions.getWebResult();
443        if (cursor == null) return null;
444        int count = cursor.getCount();
445        ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
446        boolean usingWebCorpus = isSearchCorpusWeb();
447        for (int i = 0; i < count; i++) {
448            cursor.moveTo(i);
449            if (!usingWebCorpus || cursor.isWebSearchSuggestion()) {
450                String text1 = cursor.getSuggestionText1();
451                completions.add(new CompletionInfo(i, i, text1));
452            }
453        }
454        return completions.toArray(new CompletionInfo[completions.size()]);
455    }
456
457    protected void onSuggestionsChanged() {
458        updateInputMethodSuggestions();
459    }
460
461    /**
462     * Checks if the corpus used for typed searches is the web corpus.
463     */
464    protected boolean isSearchCorpusWeb() {
465        Corpus corpus = getSearchCorpus();
466        return corpus != null && corpus.isWebCorpus();
467    }
468
469    protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter,
470            int position, int keyCode, KeyEvent event) {
471        // Treat enter or search as a click
472        if (       keyCode == KeyEvent.KEYCODE_ENTER
473                || keyCode == KeyEvent.KEYCODE_SEARCH
474                || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
475            if (adapter != null) {
476                SuggestionsAdapter<?> suggestionsAdapter = adapter;
477                suggestionsAdapter.onSuggestionClicked(position);
478                return true;
479            } else {
480                return false;
481            }
482        }
483
484        return false;
485    }
486
487    protected boolean onSearchClicked(int method) {
488        if (mSearchClickListener != null) {
489            return mSearchClickListener.onSearchClicked(method);
490        }
491        return false;
492    }
493
494    /**
495     * Filters the suggestions list when the search text changes.
496     */
497    private class SearchTextWatcher implements TextWatcher {
498        public void afterTextChanged(Editable s) {
499            boolean empty = s.length() == 0;
500            if (empty != mQueryWasEmpty) {
501                mQueryWasEmpty = empty;
502                updateUi(empty);
503            }
504            if (mUpdateSuggestions) {
505                if (mQueryListener != null) {
506                    mQueryListener.onQueryChanged();
507                }
508            }
509        }
510
511        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
512        }
513
514        public void onTextChanged(CharSequence s, int start, int before, int count) {
515        }
516    }
517
518    /**
519     * Handles key events on the suggestions list view.
520     */
521    protected class SuggestionsViewKeyListener implements View.OnKeyListener {
522        public boolean onKey(View v, int keyCode, KeyEvent event) {
523            if (event.getAction() == KeyEvent.ACTION_DOWN
524                    && v instanceof SuggestionsView) {
525                SuggestionsView view = ((SuggestionsView) v);
526                int position = view.getSelectedPosition();
527                if (onSuggestionKeyDown(view.getSuggestionsAdapter(), position, keyCode, event)) {
528                    return true;
529                }
530            }
531            return forwardKeyToQueryTextView(keyCode, event);
532        }
533    }
534
535    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
536
537        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
538                int totalItemCount) {
539        }
540
541        public void onScrollStateChanged(AbsListView view, int scrollState) {
542            considerHidingInputMethod();
543        }
544    }
545
546    /**
547     * Listens for clicks on the source selector.
548     */
549    private class SearchGoButtonClickListener implements View.OnClickListener {
550        public void onClick(View view) {
551            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
552        }
553    }
554
555    /**
556     * Handles non-text keys in the query text view.
557     */
558    private class QueryTextViewKeyListener implements View.OnKeyListener {
559        public boolean onKey(View view, int keyCode, KeyEvent event) {
560            // Handle IME search action key
561            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
562                // if no action was taken, consume the key event so that the keyboard
563                // remains on screen.
564                return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
565            }
566            return false;
567        }
568    }
569
570    /**
571     * Handles key events on the search and voice search buttons,
572     * by refocusing to EditText.
573     */
574    private class ButtonsKeyListener implements View.OnKeyListener {
575        public boolean onKey(View v, int keyCode, KeyEvent event) {
576            return forwardKeyToQueryTextView(keyCode, event);
577        }
578    }
579
580    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
581        if (!event.isSystem() && !isDpadKey(keyCode)) {
582            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
583            if (mQueryTextView.requestFocus()) {
584                return mQueryTextView.dispatchKeyEvent(event);
585            }
586        }
587        return false;
588    }
589
590    private boolean isDpadKey(int keyCode) {
591        switch (keyCode) {
592            case KeyEvent.KEYCODE_DPAD_UP:
593            case KeyEvent.KEYCODE_DPAD_DOWN:
594            case KeyEvent.KEYCODE_DPAD_LEFT:
595            case KeyEvent.KEYCODE_DPAD_RIGHT:
596            case KeyEvent.KEYCODE_DPAD_CENTER:
597                return true;
598            default:
599                return false;
600        }
601    }
602
603    /**
604     * Hides the input method when the suggestions get focus.
605     */
606    private class SuggestListFocusListener implements OnFocusChangeListener {
607        public void onFocusChange(View v, boolean focused) {
608            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
609            if (focused) {
610                considerHidingInputMethod();
611            }
612        }
613    }
614
615    private class QueryTextViewFocusListener implements OnFocusChangeListener {
616        public void onFocusChange(View v, boolean focused) {
617            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
618            if (focused) {
619                // The query box got focus, show the input method
620                showInputMethodForQuery();
621            }
622        }
623    }
624
625    protected class SuggestionsObserver extends DataSetObserver {
626        @Override
627        public void onChanged() {
628            onSuggestionsChanged();
629        }
630    }
631
632    public interface QueryListener {
633        void onQueryChanged();
634    }
635
636    public interface SearchClickListener {
637        boolean onSearchClicked(int method);
638    }
639
640    private class CloseClickListener implements OnClickListener {
641
642        public void onClick(View v) {
643            if (!TextUtils.isEmpty(mQueryTextView.getText())) {
644                mQueryTextView.setText("");
645            } else {
646                mExitClickListener.onClick(v);
647            }
648        }
649    }
650}
651