SearchActivity.java revision b42184f1e6a1b7bb22797ff92cae696753aca770
1/*
2 * Copyright (C) 2009 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;
18
19import com.android.common.Search;
20import com.android.quicksearchbox.ui.CorpusViewFactory;
21import com.android.quicksearchbox.ui.QueryTextView;
22import com.android.quicksearchbox.ui.SuggestionClickListener;
23import com.android.quicksearchbox.ui.SuggestionsAdapter;
24import com.android.quicksearchbox.ui.SuggestionsView;
25import com.android.quicksearchbox.util.Consumer;
26import com.android.quicksearchbox.util.Consumers;
27import com.google.common.annotations.VisibleForTesting;
28import com.google.common.base.CharMatcher;
29
30import android.app.Activity;
31import android.app.SearchManager;
32import android.content.DialogInterface;
33import android.content.Intent;
34import android.database.DataSetObserver;
35import android.graphics.drawable.Drawable;
36import android.net.Uri;
37import android.os.Bundle;
38import android.os.Debug;
39import android.os.Handler;
40import android.text.Editable;
41import android.text.TextUtils;
42import android.text.TextWatcher;
43import android.util.Log;
44import android.view.KeyEvent;
45import android.view.Menu;
46import android.view.View;
47import android.view.View.OnFocusChangeListener;
48import android.view.inputmethod.CompletionInfo;
49import android.view.inputmethod.InputMethodManager;
50import android.widget.AbsListView;
51import android.widget.ImageButton;
52import android.widget.ImageView;
53
54import java.io.File;
55import java.util.ArrayList;
56import java.util.Arrays;
57import java.util.Collection;
58import java.util.List;
59import java.util.Set;
60
61/**
62 * The main activity for Quick Search Box. Shows the search UI.
63 *
64 */
65public class SearchActivity extends Activity {
66
67    private static final boolean DBG = false;
68    private static final String TAG = "QSB.SearchActivity";
69    private static final boolean TRACE = false;
70
71    private static final String SCHEME_CORPUS = "qsb.corpus";
72
73    public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS
74            = "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS";
75
76    // The string used for privateImeOptions to identify to the IME that it should not show
77    // a microphone button since one already exists in the search dialog.
78    // TODO: This should move to android-common or something.
79    private static final String IME_OPTION_NO_MICROPHONE = "nm";
80
81    // Keys for the saved instance state.
82    private static final String INSTANCE_KEY_CORPUS = "corpus";
83    private static final String INSTANCE_KEY_QUERY = "query";
84
85    // Measures time from for last onCreate()/onNewIntent() call.
86    private LatencyTracker mStartLatencyTracker;
87    // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
88    private boolean mStarting;
89    // True if the user has taken some action, e.g. launching a search, voice search,
90    // or suggestions, since QSB was last started.
91    private boolean mTookAction;
92
93    private CorpusSelectionDialog mCorpusSelectionDialog;
94
95    protected SuggestionsAdapter mSuggestionsAdapter;
96
97    // The list adapter for showing results other than query completion
98    // suggestions
99    protected SuggestionsAdapter mResultsAdapter;
100
101    private CorporaObserver mCorporaObserver;
102
103    protected QueryTextView mQueryTextView;
104    // True if the query was empty on the previous call to updateQuery()
105    protected boolean mQueryWasEmpty = true;
106    protected Drawable mQueryTextEmptyBg;
107    protected Drawable mQueryTextNotEmptyBg;
108
109    protected SuggestionsView mSuggestionsView;
110
111    // View that shows the results other than the query completions
112    protected SuggestionsView mResultsView;
113
114    protected boolean mSeparateResults;
115
116    protected ImageButton mSearchGoButton;
117    protected ImageButton mVoiceSearchButton;
118    protected ImageButton mCorpusIndicator;
119
120    protected ImageView mSettingsButton;
121
122    private Corpus mCorpus;
123    private Bundle mAppSearchData;
124    private boolean mUpdateSuggestions;
125
126    private final Handler mHandler = new Handler();
127    private final Runnable mUpdateSuggestionsTask = new Runnable() {
128        public void run() {
129            updateSuggestions(getQuery());
130        }
131    };
132
133    private final Runnable mShowInputMethodTask = new Runnable() {
134        public void run() {
135            showInputMethodForQuery();
136        }
137    };
138
139    private OnDestroyListener mDestroyListener;
140
141    /** Called when the activity is first created. */
142    @Override
143    public void onCreate(Bundle savedInstanceState) {
144        if (TRACE) startMethodTracing();
145        recordStartTime();
146        if (DBG) Log.d(TAG, "onCreate()");
147        super.onCreate(savedInstanceState);
148
149        setContentView();
150
151        SuggestListFocusListener suggestionFocusListener = new SuggestListFocusListener();
152        SuggestionsViewKeyListener suggestionViewKeyListener = new SuggestionsViewKeyListener();
153        InputMethodCloser imeCloser = new InputMethodCloser();
154
155        mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text);
156
157        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
158        mSuggestionsView.setOnScrollListener(imeCloser);
159        mSuggestionsView.setOnKeyListener(suggestionViewKeyListener);
160        mSuggestionsView.setOnFocusChangeListener(suggestionFocusListener);
161
162        mResultsView = (SuggestionsView) findViewById(R.id.shortcuts);
163        mSeparateResults = mResultsView != null;
164
165        if (mSeparateResults) {
166            mSuggestionsAdapter = getQsbApplication().createWebSuggestionsAdapter();
167            mResultsAdapter = getQsbApplication().createResultSuggestionsAdapter();
168            mResultsAdapter.setSuggestionClickListener(new ClickHandler(mResultsAdapter));
169            mResultsAdapter.setOnFocusChangeListener(suggestionFocusListener);
170            mResultsView.setOnScrollListener(imeCloser);
171            mResultsView.setOnFocusChangeListener(suggestionFocusListener);
172            mResultsView.setOnKeyListener(suggestionViewKeyListener);
173        } else {
174            mSuggestionsAdapter = getQsbApplication().createBlendingSuggestionsAdapter();
175        }
176        mSuggestionsAdapter.setSuggestionClickListener(new ClickHandler(mSuggestionsAdapter));
177        mSuggestionsAdapter.setOnFocusChangeListener(suggestionFocusListener);
178
179        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
180        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
181        mCorpusIndicator = (ImageButton) findViewById(R.id.corpus_indicator);
182        mSettingsButton = (ImageView) findViewById(R.id.settings_icon);
183
184        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
185        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
186        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
187        mQueryTextView.setSuggestionClickListener(new ClickHandler(mSuggestionsAdapter));
188        mQueryTextEmptyBg = mQueryTextView.getBackground();
189
190        if (mCorpusIndicator != null) {
191            mCorpusIndicator.setOnClickListener(new CorpusIndicatorClickListener());
192        }
193
194        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
195
196        mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener());
197
198        if (mSettingsButton != null) {
199            mSettingsButton.setOnClickListener(new SettingsButtonClickListener());
200        }
201
202        ButtonsKeyListener buttonsKeyListener = new ButtonsKeyListener();
203        mSearchGoButton.setOnKeyListener(buttonsKeyListener);
204        mVoiceSearchButton.setOnKeyListener(buttonsKeyListener);
205        if (mCorpusIndicator != null) {
206            mCorpusIndicator.setOnKeyListener(buttonsKeyListener);
207        }
208
209        mUpdateSuggestions = true;
210
211        // First get setup from intent
212        Intent intent = getIntent();
213        setupFromIntent(intent);
214        // Then restore any saved instance state
215        restoreInstanceState(savedInstanceState);
216
217        mSuggestionsAdapter.registerDataSetObserver(new SuggestionsObserver());
218
219        // Do this at the end, to avoid updating the list view when setSource()
220        // is called.
221        mSuggestionsView.setAdapter(mSuggestionsAdapter);
222
223        if (mSeparateResults) {
224            mResultsAdapter.registerDataSetObserver(new SuggestionsObserver());
225            mResultsView.setAdapter(mResultsAdapter);
226        }
227        mCorporaObserver = new CorporaObserver();
228        getCorpora().registerDataSetObserver(mCorporaObserver);
229    }
230
231    protected void setContentView() {
232        setContentView(R.layout.search_activity);
233    }
234
235    private void startMethodTracing() {
236        File traceDir = getDir("traces", 0);
237        String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath();
238        Debug.startMethodTracing(traceFile);
239    }
240
241    @Override
242    protected void onNewIntent(Intent intent) {
243        if (DBG) Log.d(TAG, "onNewIntent()");
244        recordStartTime();
245        setIntent(intent);
246        setupFromIntent(intent);
247    }
248
249    private void recordStartTime() {
250        mStartLatencyTracker = new LatencyTracker();
251        mStarting = true;
252        mTookAction = false;
253    }
254
255    protected void restoreInstanceState(Bundle savedInstanceState) {
256        if (savedInstanceState == null) return;
257        String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS);
258        String query = savedInstanceState.getString(INSTANCE_KEY_QUERY);
259        setCorpus(corpusName);
260        setQuery(query, false);
261    }
262
263    @Override
264    protected void onSaveInstanceState(Bundle outState) {
265        super.onSaveInstanceState(outState);
266        // We don't save appSearchData, since we always get the value
267        // from the intent and the user can't change it.
268
269        outState.putString(INSTANCE_KEY_CORPUS, getCorpusName());
270        outState.putString(INSTANCE_KEY_QUERY, getQuery());
271    }
272
273    private void setupFromIntent(Intent intent) {
274        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
275        String corpusName = getCorpusNameFromUri(intent.getData());
276        String query = intent.getStringExtra(SearchManager.QUERY);
277        Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
278        boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
279
280        setCorpus(corpusName);
281        setQuery(query, selectAll);
282        mAppSearchData = appSearchData;
283
284        if (startedIntoCorpusSelectionDialog()) {
285            showCorpusSelectionDialog();
286        }
287    }
288
289    public boolean startedIntoCorpusSelectionDialog() {
290        return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction());
291    }
292
293    /**
294     * Removes corpus selector intent action, so that BACK works normally after
295     * dismissing and reopening the corpus selector.
296     */
297    private void clearStartedIntoCorpusSelectionDialog() {
298        Intent oldIntent = getIntent();
299        if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) {
300            Intent newIntent = new Intent(oldIntent);
301            newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH);
302            setIntent(newIntent);
303        }
304    }
305
306    public static Uri getCorpusUri(Corpus corpus) {
307        if (corpus == null) return null;
308        return new Uri.Builder()
309                .scheme(SCHEME_CORPUS)
310                .authority(corpus.getName())
311                .build();
312    }
313
314    private String getCorpusNameFromUri(Uri uri) {
315        if (uri == null) return null;
316        if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
317        return uri.getAuthority();
318    }
319
320    private Corpus getCorpus(String sourceName) {
321        if (sourceName == null) return null;
322        Corpus corpus = getCorpora().getCorpus(sourceName);
323        if (corpus == null) {
324            Log.w(TAG, "Unknown corpus " + sourceName);
325            return null;
326        }
327        return corpus;
328    }
329
330    private void setCorpus(String corpusName) {
331        if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")");
332        mCorpus = getCorpus(corpusName);
333        Drawable sourceIcon;
334        if (mCorpus == null) {
335            sourceIcon = getCorpusViewFactory().getGlobalSearchIcon();
336        } else {
337            sourceIcon = mCorpus.getCorpusIcon();
338        }
339        mSuggestionsAdapter.setCorpus(mCorpus);
340        if (mResultsAdapter != null) {
341            mResultsAdapter.setCorpus(mCorpus);
342        }
343        if (mCorpusIndicator != null) {
344            mCorpusIndicator.setImageDrawable(sourceIcon);
345        }
346
347        updateUi(getQuery().length() == 0);
348    }
349
350    private String getCorpusName() {
351        return mCorpus == null ? null : mCorpus.getName();
352    }
353
354    private QsbApplication getQsbApplication() {
355        return QsbApplication.get(this);
356    }
357
358    protected boolean separateResults() {
359        return mSeparateResults;
360    }
361
362    private Config getConfig() {
363        return getQsbApplication().getConfig();
364    }
365
366    private Corpora getCorpora() {
367        return getQsbApplication().getCorpora();
368    }
369
370    private CorpusRanker getCorpusRanker() {
371        return getQsbApplication().getCorpusRanker();
372    }
373
374    private ShortcutRepository getShortcutRepository() {
375        return getQsbApplication().getShortcutRepository();
376    }
377
378    private SuggestionsProvider getSuggestionsProvider() {
379        return getQsbApplication().getSuggestionsProvider();
380    }
381
382    private CorpusViewFactory getCorpusViewFactory() {
383        return getQsbApplication().getCorpusViewFactory();
384    }
385
386    private VoiceSearch getVoiceSearch() {
387        return QsbApplication.get(this).getVoiceSearch();
388    }
389
390    private Logger getLogger() {
391        return getQsbApplication().getLogger();
392    }
393
394    @VisibleForTesting
395    public void setOnDestroyListener(OnDestroyListener l) {
396        mDestroyListener = l;
397    }
398
399    @Override
400    protected void onDestroy() {
401        if (DBG) Log.d(TAG, "onDestroy()");
402        getCorpora().unregisterDataSetObserver(mCorporaObserver);
403        mSuggestionsView.setAdapter(null);  // closes mSuggestionsAdapter
404        if (mResultsView != null) {
405            mResultsView.setAdapter(null);
406        }
407        super.onDestroy();
408        if (mDestroyListener != null) {
409            mDestroyListener.onDestroyed();
410        }
411    }
412
413    @Override
414    protected void onStop() {
415        if (DBG) Log.d(TAG, "onStop()");
416        if (!mTookAction) {
417            // TODO: This gets logged when starting other activities, e.g. by opening the search
418            // settings, or clicking a notification in the status bar.
419            // TODO we should log both sets of suggestions in 2-pane mode
420            getLogger().logExit(getCurrentSuggestions(), getQuery().length());
421        }
422        // Close all open suggestion cursors. The query will be redone in onResume()
423        // if we come back to this activity.
424        mSuggestionsAdapter.setSuggestions(null);
425        if (mResultsAdapter != null) {
426            mResultsAdapter.setSuggestions(null);
427        }
428        getQsbApplication().getShortcutRefresher().reset();
429        dismissCorpusSelectionDialog();
430        super.onStop();
431    }
432
433    @Override
434    protected void onRestart() {
435        if (DBG) Log.d(TAG, "onRestart()");
436        super.onRestart();
437    }
438
439    @Override
440    protected void onResume() {
441        if (DBG) Log.d(TAG, "onResume()");
442        super.onResume();
443        updateSuggestionsBuffered();
444        if (!isCorpusSelectionDialogShowing()) {
445            mQueryTextView.requestFocus();
446        }
447        if (TRACE) Debug.stopMethodTracing();
448    }
449
450    @Override
451    public boolean onCreateOptionsMenu(Menu menu) {
452        super.onCreateOptionsMenu(menu);
453        SearchSettings.addSearchSettingsMenuItem(this, menu);
454        return true;
455    }
456
457    @Override
458    public void onWindowFocusChanged(boolean hasFocus) {
459        super.onWindowFocusChanged(hasFocus);
460        if (hasFocus) {
461            // Launch the IME after a bit
462            mHandler.postDelayed(mShowInputMethodTask, 0);
463        }
464    }
465
466    protected String getQuery() {
467        CharSequence q = mQueryTextView.getText();
468        return q == null ? "" : q.toString();
469    }
470
471    /**
472     * Sets the text in the query box. Does not update the suggestions.
473     */
474    private void setQuery(String query, boolean selectAll) {
475        mUpdateSuggestions = false;
476        mQueryTextView.setText(query);
477        mQueryTextView.setTextSelection(selectAll);
478        mUpdateSuggestions = true;
479    }
480
481    protected void updateUi(boolean queryEmpty) {
482        updateQueryTextView(queryEmpty);
483        updateSearchGoButton(queryEmpty);
484        updateVoiceSearchButton(queryEmpty);
485    }
486
487    private void updateQueryTextView(boolean queryEmpty) {
488        if (queryEmpty) {
489            if (isSearchCorpusWeb()) {
490                mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg);
491                mQueryTextView.setHint(null);
492            } else {
493                if (mQueryTextNotEmptyBg == null) {
494                    mQueryTextNotEmptyBg =
495                            getResources().getDrawable(R.drawable.textfield_search_empty);
496                }
497                mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg);
498                mQueryTextView.setHint(mCorpus.getHint());
499            }
500        } else {
501            mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
502        }
503    }
504
505    private void updateSearchGoButton(boolean queryEmpty) {
506        if (queryEmpty) {
507            mSearchGoButton.setVisibility(View.GONE);
508        } else {
509            mSearchGoButton.setVisibility(View.VISIBLE);
510        }
511    }
512
513    protected void updateVoiceSearchButton(boolean queryEmpty) {
514        if (queryEmpty && getVoiceSearch().shouldShowVoiceSearch(mCorpus)) {
515            mVoiceSearchButton.setVisibility(View.VISIBLE);
516            mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
517        } else {
518            mVoiceSearchButton.setVisibility(View.GONE);
519            mQueryTextView.setPrivateImeOptions(null);
520        }
521    }
522
523    protected void showCorpusSelectionDialog() {
524        if (mCorpusSelectionDialog == null) {
525            mCorpusSelectionDialog = createCorpusSelectionDialog();
526            mCorpusSelectionDialog.setOwnerActivity(this);
527            mCorpusSelectionDialog.setOnDismissListener(new CorpusSelectorDismissListener());
528            mCorpusSelectionDialog.setOnCorpusSelectedListener(new CorpusSelectionListener());
529        }
530        mCorpusSelectionDialog.show(mCorpus);
531    }
532
533    protected CorpusSelectionDialog createCorpusSelectionDialog() {
534        return new CorpusSelectionDialog(this);
535    }
536
537    protected boolean isCorpusSelectionDialogShowing() {
538        return mCorpusSelectionDialog != null && mCorpusSelectionDialog.isShowing();
539    }
540
541    protected void dismissCorpusSelectionDialog() {
542        if (mCorpusSelectionDialog != null) {
543            mCorpusSelectionDialog.dismiss();
544        }
545    }
546
547    /**
548     * @return true if a search was performed as a result of this click, false otherwise.
549     */
550    protected boolean onSearchClicked(int method) {
551        String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
552        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
553
554        // Don't do empty queries
555        if (TextUtils.getTrimmedLength(query) == 0) return false;
556
557        Corpus searchCorpus = getSearchCorpus();
558        if (searchCorpus == null) return false;
559
560        mTookAction = true;
561
562        // Log search start
563        getLogger().logSearch(mCorpus, method, query.length());
564
565        // Create shortcut
566        SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query);
567        if (searchShortcut != null) {
568            ListSuggestionCursor cursor = new ListSuggestionCursor(query);
569            cursor.add(searchShortcut);
570            getShortcutRepository().reportClick(cursor, 0);
571        }
572
573        // Start search
574        Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
575        launchIntent(intent);
576        return true;
577    }
578
579    protected void onVoiceSearchClicked() {
580        if (DBG) Log.d(TAG, "Voice Search clicked");
581        Corpus searchCorpus = getSearchCorpus();
582        if (searchCorpus == null) return;
583
584        mTookAction = true;
585
586        // Log voice search start
587        getLogger().logVoiceSearch(searchCorpus);
588
589        // Start voice search
590        Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData);
591        launchIntent(intent);
592    }
593
594    protected void onSettingsClicked() {
595        SearchSettings.launchSettings(this);
596    }
597
598    /**
599     * Gets the corpus to use for any searches. This is the web corpus in "All" mode,
600     * and the selected corpus otherwise.
601     */
602    protected Corpus getSearchCorpus() {
603        if (mCorpus != null) {
604            return mCorpus;
605        } else {
606            Corpus webCorpus = getCorpora().getWebCorpus();
607            if (webCorpus == null) {
608                Log.e(TAG, "No web corpus");
609            }
610            return webCorpus;
611        }
612    }
613
614    /**
615     * Checks if the corpus used for typed searches is the web corpus.
616     */
617    protected boolean isSearchCorpusWeb() {
618        Corpus corpus = getSearchCorpus();
619        return corpus != null && corpus.isWebCorpus();
620    }
621
622    @Deprecated
623    protected SuggestionCursor getCurrentSuggestions() {
624        return mSuggestionsAdapter.getCurrentSuggestions();
625    }
626
627    protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) {
628        SuggestionCursor suggestions = adapter.getCurrentSuggestions();
629        if (suggestions == null) {
630            return null;
631        }
632        int count = suggestions.getCount();
633        if (position < 0 || position >= count) {
634            Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
635            return null;
636        }
637        suggestions.moveTo(position);
638        return suggestions;
639    }
640
641    private Suggestions getSuggestions() {
642        return mSuggestionsAdapter.getSuggestions();
643    }
644
645    protected Set<Corpus> getCurrentIncludedCorpora() {
646        Suggestions suggestions = getSuggestions();
647        return suggestions == null  ? null : suggestions.getIncludedCorpora();
648    }
649
650    protected void launchIntent(Intent intent) {
651        if (intent == null) {
652            return;
653        }
654        try {
655            startActivity(intent);
656        } catch (RuntimeException ex) {
657            // Since the intents for suggestions specified by suggestion providers,
658            // guard against them not being handled, not allowed, etc.
659            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
660        }
661    }
662
663    protected boolean launchSuggestion(SuggestionsAdapter adapter, int position) {
664        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
665        if (suggestions == null) return false;
666
667        if (DBG) Log.d(TAG, "Launching suggestion " + position);
668        mTookAction = true;
669
670        // Log suggestion click
671        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
672                Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
673
674        // Create shortcut
675        getShortcutRepository().reportClick(suggestions, position);
676
677        // Launch intent
678        suggestions.moveTo(position);
679        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
680        launchIntent(intent);
681
682        return true;
683    }
684
685    protected void clickedQuickContact(SuggestionsAdapter adapter, int position) {
686        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
687        if (suggestions == null) return;
688
689        if (DBG) Log.d(TAG, "Used suggestion " + position);
690        mTookAction = true;
691
692        // Log suggestion click
693        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
694                Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
695
696        // Create shortcut
697        getShortcutRepository().reportClick(suggestions, position);
698    }
699
700    protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
701        if (DBG) Log.d(TAG, "Long clicked on suggestion " + position);
702        return false;
703    }
704
705    protected boolean onSuggestionKeyDown(SuggestionsAdapter adapter,
706            int position, int keyCode, KeyEvent event) {
707        // Treat enter or search as a click
708        if (       keyCode == KeyEvent.KEYCODE_ENTER
709                || keyCode == KeyEvent.KEYCODE_SEARCH
710                || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
711            return launchSuggestion(adapter, position);
712        }
713
714        return false;
715    }
716
717    protected void refineSuggestion(SuggestionsAdapter adapter, int position) {
718        if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
719        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
720        if (suggestions == null) {
721            return;
722        }
723        String query = suggestions.getSuggestionQuery();
724        if (TextUtils.isEmpty(query)) {
725            return;
726        }
727
728        // Log refine click
729        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
730                Logger.SUGGESTION_CLICK_TYPE_REFINE);
731
732        // Put query + space in query text view
733        String queryWithSpace = query + ' ';
734        setQuery(queryWithSpace, false);
735        updateSuggestions(queryWithSpace);
736        mQueryTextView.requestFocus();
737    }
738
739    /**
740     * Hides the input method.
741     */
742    protected void considerHidingInputMethod() {
743        if (getConfig().isKeyboardDismissedOnScroll()) {
744            mQueryTextView.hideInputMethod();
745        }
746    }
747
748    protected void showInputMethodForQuery() {
749        mQueryTextView.showInputMethod();
750    }
751
752    protected void onSuggestionListFocusChange(SuggestionsView suggestions, boolean focused) {
753    }
754
755    protected void onQueryTextViewFocusChange(boolean focused) {
756    }
757
758    /**
759     * Hides the input method when the suggestions get focus.
760     */
761    private class SuggestListFocusListener implements OnFocusChangeListener {
762        public void onFocusChange(View v, boolean focused) {
763            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
764            if (focused && v instanceof SuggestionsView) {
765                SuggestionsView view = (SuggestionsView) v;
766                considerHidingInputMethod();
767                onSuggestionListFocusChange(view, focused);
768            }
769        }
770    }
771
772    private class QueryTextViewFocusListener implements OnFocusChangeListener {
773        public void onFocusChange(View v, boolean focused) {
774            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
775            if (focused) {
776                // The query box got focus, show the input method
777                showInputMethodForQuery();
778            }
779            onQueryTextViewFocusChange(focused);
780        }
781    }
782
783    private void updateSuggestionsBuffered() {
784        mHandler.removeCallbacks(mUpdateSuggestionsTask);
785        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
786        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
787    }
788
789    private void gotSuggestions(Suggestions suggestions) {
790        if (mStarting) {
791            mStarting = false;
792            String source = getIntent().getStringExtra(Search.SOURCE);
793            int latency = mStartLatencyTracker.getLatency();
794            getLogger().logStart(latency, source, mCorpus,
795                    suggestions == null ? null : suggestions.getExpectedCorpora());
796            getQsbApplication().onStartupComplete();
797        }
798    }
799
800    private List<Corpus> getCorporaToQuery() {
801        if (mCorpus == null) {
802            // No corpus selected, use all enabled corpora
803            // TODO: This should be done asynchronously, since it can be expensive
804            return getCorpusRanker().getRankedCorpora();
805        } else {
806            List<Corpus> corpora = new ArrayList<Corpus>();
807            if (separateResults()) {
808                // In two-pane mode, we always need to query the web corpus.
809                Corpus webCorpus = getCorpora().getWebCorpus();
810                if (webCorpus != null && webCorpus != mCorpus) corpora.add(webCorpus);
811            }
812            // Query the selected corpus
813            corpora.add(mCorpus);
814            return corpora;
815        }
816    }
817
818    protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
819            final Suggestions suggestions) {
820        ShortcutRepository shortcutRepo = getShortcutRepository();
821        if (shortcutRepo == null) return;
822        Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
823                new Consumer<ShortcutCursor>() {
824            public boolean consume(ShortcutCursor shortcuts) {
825                suggestions.setShortcuts(shortcuts);
826                return true;
827            }
828        });
829        shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer);
830    }
831
832    protected void updateSuggestions(String query) {
833        query = CharMatcher.WHITESPACE.trimLeadingFrom(query);
834        if (DBG) Log.d(TAG, "getSuggestions(\""+query+"\","+mCorpus + ")");
835        getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
836
837        List<Corpus> corporaToQuery = getCorporaToQuery();
838        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
839                query, corporaToQuery);
840        getShortcutsForQuery(query, corporaToQuery, suggestions);
841
842        // Log start latency if this is the first suggestions update
843        gotSuggestions(suggestions);
844
845        suggestions.acquire();
846        mSuggestionsAdapter.setSuggestions(suggestions);
847        if (mResultsAdapter != null) {
848            suggestions.acquire();
849            mResultsAdapter.setSuggestions(suggestions);
850        }
851    }
852
853    /**
854     * If the input method is in fullscreen mode, and the selector corpus
855     * is All or Web, use the web search suggestions as completions.
856     */
857    protected void updateInputMethodSuggestions() {
858        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
859        if (imm == null || !imm.isFullscreenMode()) return;
860        Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
861        if (suggestions == null) return;
862        CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
863        if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
864        imm.displayCompletions(mQueryTextView, completions);
865    }
866
867    private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
868        // TODO: This should also include include web search shortcuts
869        CorpusResult cursor = suggestions.getWebResult();
870        if (cursor == null) return null;
871        int count = cursor.getCount();
872        ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
873        boolean usingWebCorpus = isSearchCorpusWeb();
874        for (int i = 0; i < count; i++) {
875            cursor.moveTo(i);
876            if (!usingWebCorpus || cursor.isWebSearchSuggestion()) {
877                String text1 = cursor.getSuggestionText1();
878                completions.add(new CompletionInfo(i, i, text1));
879            }
880        }
881        return completions.toArray(new CompletionInfo[completions.size()]);
882    }
883
884    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
885        if (!event.isSystem() && !isDpadKey(keyCode)) {
886            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
887            if (mQueryTextView.requestFocus()) {
888                return mQueryTextView.dispatchKeyEvent(event);
889            }
890        }
891        return false;
892    }
893
894    private boolean isDpadKey(int keyCode) {
895        switch (keyCode) {
896            case KeyEvent.KEYCODE_DPAD_UP:
897            case KeyEvent.KEYCODE_DPAD_DOWN:
898            case KeyEvent.KEYCODE_DPAD_LEFT:
899            case KeyEvent.KEYCODE_DPAD_RIGHT:
900            case KeyEvent.KEYCODE_DPAD_CENTER:
901                return true;
902            default:
903                return false;
904        }
905    }
906
907    /**
908     * Filters the suggestions list when the search text changes.
909     */
910    private class SearchTextWatcher implements TextWatcher {
911        public void afterTextChanged(Editable s) {
912            boolean empty = s.length() == 0;
913            if (empty != mQueryWasEmpty) {
914                mQueryWasEmpty = empty;
915                updateUi(empty);
916            }
917            if (mUpdateSuggestions) {
918                updateSuggestionsBuffered();
919            }
920        }
921
922        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
923        }
924
925        public void onTextChanged(CharSequence s, int start, int before, int count) {
926        }
927    }
928
929    /**
930     * Handles non-text keys in the query text view.
931     */
932    private class QueryTextViewKeyListener implements View.OnKeyListener {
933        public boolean onKey(View view, int keyCode, KeyEvent event) {
934            // Handle IME search action key
935            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
936                // if no action was taken, consume the key event so that the keyboard
937                // remains on screen.
938                return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
939            }
940            return false;
941        }
942    }
943
944    /**
945     * Handles key events on the search and voice search buttons,
946     * by refocusing to EditText.
947     */
948    private class ButtonsKeyListener implements View.OnKeyListener {
949        public boolean onKey(View v, int keyCode, KeyEvent event) {
950            return forwardKeyToQueryTextView(keyCode, event);
951        }
952    }
953
954    /**
955     * Handles key events on the suggestions list view.
956     */
957    private class SuggestionsViewKeyListener implements View.OnKeyListener {
958        public boolean onKey(View v, int keyCode, KeyEvent event) {
959            if (event.getAction() == KeyEvent.ACTION_DOWN
960                    && v instanceof SuggestionsView) {
961                SuggestionsView view = ((SuggestionsView) v);
962                int position = view.getSelectedPosition();
963                if (onSuggestionKeyDown(view.getAdapter(), position, keyCode, event)) {
964                    return true;
965                }
966            }
967            return forwardKeyToQueryTextView(keyCode, event);
968        }
969    }
970
971    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
972
973        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
974                int totalItemCount) {
975        }
976
977        public void onScrollStateChanged(AbsListView view, int scrollState) {
978            considerHidingInputMethod();
979        }
980    }
981
982    private class ClickHandler implements SuggestionClickListener {
983        private final SuggestionsAdapter mAdapter;
984
985        public ClickHandler(SuggestionsAdapter suggestionsAdapter) {
986            mAdapter = suggestionsAdapter;
987        }
988
989        public void onSuggestionQuickContactClicked(int position) {
990            clickedQuickContact(mAdapter, position);
991        }
992
993        public void onSuggestionClicked(int position) {
994            launchSuggestion(mAdapter, position);
995        }
996
997        public boolean onSuggestionLongClicked(int position) {
998            return SearchActivity.this.onSuggestionLongClicked(mAdapter, position);
999        }
1000
1001        public void onSuggestionQueryRefineClicked(int position) {
1002            refineSuggestion(mAdapter, position);
1003        }
1004    }
1005
1006    /**
1007     * Listens for clicks on the source selector.
1008     */
1009    private class SearchGoButtonClickListener implements View.OnClickListener {
1010        public void onClick(View view) {
1011            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
1012        }
1013    }
1014
1015    /**
1016     * Listens for clicks on the search button.
1017     */
1018    private class CorpusIndicatorClickListener implements View.OnClickListener {
1019        public void onClick(View view) {
1020            showCorpusSelectionDialog();
1021        }
1022    }
1023
1024    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
1025        public void onDismiss(DialogInterface dialog) {
1026            if (DBG) Log.d(TAG, "Corpus selector dismissed");
1027            clearStartedIntoCorpusSelectionDialog();
1028        }
1029    }
1030
1031    private class CorpusSelectionListener
1032            implements CorpusSelectionDialog.OnCorpusSelectedListener {
1033        public void onCorpusSelected(String corpusName) {
1034            setCorpus(corpusName);
1035            updateSuggestions(getQuery());
1036            mQueryTextView.requestFocus();
1037            showInputMethodForQuery();
1038        }
1039    }
1040
1041    /**
1042     * Listens for clicks on the voice search button.
1043     */
1044    private class VoiceSearchButtonClickListener implements View.OnClickListener {
1045        public void onClick(View view) {
1046            onVoiceSearchClicked();
1047        }
1048    }
1049
1050    /**
1051     * Listens for clicks on the settings button.
1052     */
1053    private class SettingsButtonClickListener implements View.OnClickListener {
1054        public void onClick(View view) {
1055            onSettingsClicked();
1056        }
1057    }
1058
1059    private class CorporaObserver extends DataSetObserver {
1060        @Override
1061        public void onChanged() {
1062            setCorpus(getCorpusName());
1063            updateSuggestions(getQuery());
1064        }
1065    }
1066
1067    private class SuggestionsObserver extends DataSetObserver {
1068        @Override
1069        public void onChanged() {
1070            updateInputMethodSuggestions();
1071        }
1072    }
1073
1074    public interface OnDestroyListener {
1075        void onDestroyed();
1076    }
1077
1078}
1079