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