SearchActivity.java revision 18959800eddb7127e4fd9b24e91458d0584cc257
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.SuggestionClickListener;
22import com.android.quicksearchbox.ui.SuggestionSelectionListener;
23import com.android.quicksearchbox.ui.SuggestionsAdapter;
24import com.android.quicksearchbox.ui.SuggestionsView;
25
26import android.app.Activity;
27import android.app.SearchManager;
28import android.content.DialogInterface;
29import android.content.Intent;
30import android.database.DataSetObserver;
31import android.graphics.drawable.Drawable;
32import android.net.Uri;
33import android.os.Bundle;
34import android.os.Debug;
35import android.os.Handler;
36import android.text.Editable;
37import android.text.TextUtils;
38import android.text.TextWatcher;
39import android.util.Log;
40import android.view.KeyEvent;
41import android.view.Menu;
42import android.view.View;
43import android.view.View.OnFocusChangeListener;
44import android.view.inputmethod.InputMethodManager;
45import android.widget.AbsListView;
46import android.widget.EditText;
47import android.widget.ImageButton;
48
49import java.io.File;
50import java.util.Collection;
51import java.util.List;
52
53/**
54 * The main activity for Quick Search Box. Shows the search UI.
55 *
56 */
57public class SearchActivity extends Activity {
58
59    private static final boolean DBG = false;
60    private static final String TAG = "QSB.SearchActivity";
61    private static final boolean TRACE = false;
62
63    private static final String SCHEME_CORPUS = "qsb.corpus";
64
65    public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS
66            = "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS";
67
68    // The string used for privateImeOptions to identify to the IME that it should not show
69    // a microphone button since one already exists in the search dialog.
70    // TODO: This should move to android-common or something.
71    private static final String IME_OPTION_NO_MICROPHONE = "nm";
72
73    // Keys for the saved instance state.
74    private static final String INSTANCE_KEY_CORPUS = "corpus";
75    private static final String INSTANCE_KEY_USER_QUERY = "query";
76
77    // Measures time from for last onCreate()/onNewIntent() call.
78    private LatencyTracker mStartLatencyTracker;
79    // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
80    private boolean mStarting;
81    // True if the user has taken some action, e.g. launching a search, voice search,
82    // or suggestions, since QSB was last started.
83    private boolean mTookAction;
84
85    private CorpusSelectionDialog mCorpusSelectionDialog;
86
87    protected SuggestionsAdapter mSuggestionsAdapter;
88
89    private CorporaObserver mCorporaObserver;
90
91    protected EditText mQueryTextView;
92    // True if the query was empty on the previous call to updateQuery()
93    protected boolean mQueryWasEmpty = true;
94
95    protected SuggestionsView mSuggestionsView;
96
97    protected ImageButton mSearchGoButton;
98    protected ImageButton mVoiceSearchButton;
99    protected ImageButton mCorpusIndicator;
100
101    private VoiceSearch mVoiceSearch;
102
103    private Corpus mCorpus;
104    private Bundle mAppSearchData;
105    private boolean mUpdateSuggestions;
106    private String mUserQuery;
107    private boolean mSelectAll;
108
109    private final Handler mHandler = new Handler();
110    private final Runnable mUpdateSuggestionsTask = new Runnable() {
111        public void run() {
112            updateSuggestions(getQuery());
113        }
114    };
115
116    private final Runnable mShowInputMethodTask = new Runnable() {
117        public void run() {
118            showInputMethodForQuery();
119        }
120    };
121
122    /** Called when the activity is first created. */
123    @Override
124    public void onCreate(Bundle savedInstanceState) {
125        if (TRACE) startMethodTracing();
126        recordStartTime();
127        if (DBG) Log.d(TAG, "onCreate()");
128        super.onCreate(savedInstanceState);
129
130        setContentView(R.layout.search_activity);
131
132        mSuggestionsAdapter = getQsbApplication().createSuggestionsAdapter();
133
134        mQueryTextView = (EditText) findViewById(R.id.search_src_text);
135        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
136        mSuggestionsView.setSuggestionClickListener(new ClickHandler());
137        mSuggestionsView.addSuggestionSelectionListener(new SelectionHandler());
138        mSuggestionsView.setOnScrollListener(new InputMethodCloser());
139        mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
140        mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
141
142        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
143        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
144        mCorpusIndicator = (ImageButton) findViewById(R.id.corpus_indicator);
145
146        mVoiceSearch = new VoiceSearch(this);
147
148        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
149        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
150        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
151
152        mCorpusIndicator.setOnClickListener(new CorpusIndicatorClickListener());
153
154        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
155
156        mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener());
157
158        ButtonsKeyListener buttonsKeyListener = new ButtonsKeyListener();
159        mSearchGoButton.setOnKeyListener(buttonsKeyListener);
160        mVoiceSearchButton.setOnKeyListener(buttonsKeyListener);
161        mCorpusIndicator.setOnKeyListener(buttonsKeyListener);
162
163        mUpdateSuggestions = true;
164
165        // First get setup from intent
166        Intent intent = getIntent();
167        setupFromIntent(intent);
168        // Then restore any saved instance state
169        restoreInstanceState(savedInstanceState);
170
171        // Do this at the end, to avoid updating the list view when setSource()
172        // is called.
173        mSuggestionsView.setAdapter(mSuggestionsAdapter);
174
175        mCorporaObserver = new CorporaObserver();
176        getCorpora().registerDataSetObserver(mCorporaObserver);
177    }
178
179    private void startMethodTracing() {
180        File traceDir = getDir("traces", 0);
181        String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath();
182        Debug.startMethodTracing(traceFile);
183    }
184
185    @Override
186    protected void onNewIntent(Intent intent) {
187        recordStartTime();
188        setIntent(intent);
189        setupFromIntent(intent);
190    }
191
192    private void recordStartTime() {
193        mStartLatencyTracker = new LatencyTracker();
194        mStarting = true;
195        mTookAction = false;
196    }
197
198    protected void restoreInstanceState(Bundle savedInstanceState) {
199        if (savedInstanceState == null) return;
200        String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS);
201        String query = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY);
202        setCorpus(corpusName);
203        setUserQuery(query);
204    }
205
206    @Override
207    protected void onSaveInstanceState(Bundle outState) {
208        super.onSaveInstanceState(outState);
209        // We don't save appSearchData, since we always get the value
210        // from the intent and the user can't change it.
211
212        outState.putString(INSTANCE_KEY_CORPUS, getCorpusName());
213        outState.putString(INSTANCE_KEY_USER_QUERY, mUserQuery);
214    }
215
216    private void setupFromIntent(Intent intent) {
217        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
218        String corpusName = getCorpusNameFromUri(intent.getData());
219        String query = intent.getStringExtra(SearchManager.QUERY);
220        Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
221
222        setCorpus(corpusName);
223        setUserQuery(query);
224        mSelectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
225        mAppSearchData = appSearchData;
226
227        if (startedIntoCorpusSelectionDialog()) {
228            showCorpusSelectionDialog();
229        }
230    }
231
232    public boolean startedIntoCorpusSelectionDialog() {
233        return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction());
234    }
235
236    /**
237     * Removes corpus selector intent action, so that BACK works normally after
238     * dismissing and reopening the corpus selector.
239     */
240    private void clearStartedIntoCorpusSelectionDialog() {
241        Intent oldIntent = getIntent();
242        if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) {
243            Intent newIntent = new Intent(oldIntent);
244            newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH);
245            setIntent(newIntent);
246        }
247    }
248
249    public static Uri getCorpusUri(Corpus corpus) {
250        if (corpus == null) return null;
251        return new Uri.Builder()
252                .scheme(SCHEME_CORPUS)
253                .authority(corpus.getName())
254                .build();
255    }
256
257    private String getCorpusNameFromUri(Uri uri) {
258        if (uri == null) return null;
259        if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
260        return uri.getAuthority();
261    }
262
263    private Corpus getCorpus(String sourceName) {
264        if (sourceName == null) return null;
265        Corpus corpus = getCorpora().getCorpus(sourceName);
266        if (corpus == null) {
267            Log.w(TAG, "Unknown corpus " + sourceName);
268            return null;
269        }
270        return corpus;
271    }
272
273    private void setCorpus(String corpusName) {
274        if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")");
275        mCorpus = getCorpus(corpusName);
276        Drawable sourceIcon;
277        if (mCorpus == null) {
278            sourceIcon = getCorpusViewFactory().getGlobalSearchIcon();
279        } else {
280            sourceIcon = mCorpus.getCorpusIcon();
281        }
282        mSuggestionsAdapter.setCorpus(mCorpus);
283        mCorpusIndicator.setImageDrawable(sourceIcon);
284
285        updateUi(getQuery().length() == 0);
286    }
287
288    private String getCorpusName() {
289        return mCorpus == null ? null : mCorpus.getName();
290    }
291
292    private QsbApplication getQsbApplication() {
293        return (QsbApplication) getApplication();
294    }
295
296    private Config getConfig() {
297        return getQsbApplication().getConfig();
298    }
299
300    private Corpora getCorpora() {
301        return getQsbApplication().getCorpora();
302    }
303
304    private ShortcutRepository getShortcutRepository() {
305        return getQsbApplication().getShortcutRepository();
306    }
307
308    private SuggestionsProvider getSuggestionsProvider() {
309        return getQsbApplication().getSuggestionsProvider();
310    }
311
312    private CorpusRanker getCorpusRanker() {
313        return getQsbApplication().getCorpusRanker();
314    }
315
316    private CorpusViewFactory getCorpusViewFactory() {
317        return getQsbApplication().getCorpusViewFactory();
318    }
319
320    private Logger getLogger() {
321        return getQsbApplication().getLogger();
322    }
323
324    @Override
325    protected void onDestroy() {
326        if (DBG) Log.d(TAG, "onDestroy()");
327        super.onDestroy();
328        getCorpora().unregisterDataSetObserver(mCorporaObserver);
329        mSuggestionsView.setAdapter(null);  // closes mSuggestionsAdapter
330    }
331
332    @Override
333    protected void onStop() {
334        if (DBG) Log.d(TAG, "onStop()");
335        if (!mTookAction) {
336            // TODO: This gets logged when starting other activities, e.g. by opening he search
337            // settings, or clicking a notification in the status bar.
338            getLogger().logExit(getCurrentSuggestions(), getQuery().length());
339        }
340        // Close all open suggestion cursors. The query will be redone in onResume()
341        // if we come back to this activity.
342        mSuggestionsAdapter.setSuggestions(null);
343        getQsbApplication().getShortcutRefresher().reset();
344        dismissCorpusSelectionDialog();
345        super.onStop();
346    }
347
348    @Override
349    protected void onResume() {
350        if (DBG) Log.d(TAG, "onResume()");
351        super.onResume();
352        setQuery(mUserQuery, mSelectAll);
353        // Only select everything the first time after creating the activity.
354        mSelectAll = false;
355        updateSuggestionsBuffered();
356        if (!isCorpusSelectionDialogShowing()) {
357            mQueryTextView.requestFocus();
358        }
359        if (TRACE) Debug.stopMethodTracing();
360    }
361
362    @Override
363    public boolean onCreateOptionsMenu(Menu menu) {
364        super.onCreateOptionsMenu(menu);
365        SearchSettings.addSearchSettingsMenuItem(this, menu);
366        return true;
367    }
368
369    @Override
370    public void onWindowFocusChanged(boolean hasFocus) {
371        super.onWindowFocusChanged(hasFocus);
372        if (hasFocus) {
373            // Launch the IME after a bit
374            mHandler.postDelayed(mShowInputMethodTask, 0);
375        }
376    }
377
378    /**
379     * Sets the query as typed by the user. Does not update the suggestions
380     * or the text in the query box.
381     */
382    protected void setUserQuery(String userQuery) {
383        if (userQuery == null) userQuery = "";
384        mUserQuery = userQuery;
385    }
386
387    protected String getQuery() {
388        CharSequence q = mQueryTextView.getText();
389        return q == null ? "" : q.toString();
390    }
391
392    /**
393     * Restores the query entered by the user.
394     */
395    private void restoreUserQuery() {
396        if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'");
397        setQuery(mUserQuery, false);
398    }
399
400    /**
401     * Sets the text in the query box. Does not update the suggestions,
402     * and does not change the saved user-entered query.
403     * {@link #restoreUserQuery()} will restore the query to the last
404     * user-entered query.
405     */
406    private void setQuery(String query, boolean selectAll) {
407        mUpdateSuggestions = false;
408        mQueryTextView.setText(query);
409        setTextSelection(selectAll);
410        mUpdateSuggestions = true;
411    }
412
413    /**
414     * Sets the text selection in the query text view.
415     *
416     * @param selectAll If {@code true}, selects the entire query.
417     *        If {@false}, no characters are selected, and the cursor is placed
418     *        at the end of the query.
419     */
420    private void setTextSelection(boolean selectAll) {
421        if (selectAll) {
422            mQueryTextView.selectAll();
423        } else {
424            mQueryTextView.setSelection(mQueryTextView.length());
425        }
426    }
427
428    protected void updateUi(boolean queryEmpty) {
429        updateQueryTextView(queryEmpty);
430        updateSearchGoButton(queryEmpty);
431        updateVoiceSearchButton(queryEmpty);
432    }
433
434    private void updateQueryTextView(boolean queryEmpty) {
435        if (queryEmpty) {
436            if (mCorpus == null || mCorpus.isWebCorpus()) {
437                mQueryTextView.setBackgroundResource(R.drawable.textfield_search_empty_google);
438                mQueryTextView.setHint(null);
439            } else {
440                mQueryTextView.setBackgroundResource(R.drawable.textfield_search_empty);
441                mQueryTextView.setHint(mCorpus.getHint());
442            }
443        } else {
444            mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
445        }
446    }
447
448    private void updateSearchGoButton(boolean queryEmpty) {
449        if (queryEmpty) {
450            mSearchGoButton.setVisibility(View.GONE);
451        } else {
452            mSearchGoButton.setVisibility(View.VISIBLE);
453        }
454    }
455
456    protected void updateVoiceSearchButton(boolean queryEmpty) {
457        if (queryEmpty && mVoiceSearch.shouldShowVoiceSearch(mCorpus)) {
458            mVoiceSearchButton.setVisibility(View.VISIBLE);
459            mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
460        } else {
461            mVoiceSearchButton.setVisibility(View.GONE);
462            mQueryTextView.setPrivateImeOptions(null);
463        }
464    }
465
466    protected void showCorpusSelectionDialog() {
467        if (mCorpusSelectionDialog == null) {
468            mCorpusSelectionDialog = new CorpusSelectionDialog(this);
469            mCorpusSelectionDialog.setOwnerActivity(this);
470            mCorpusSelectionDialog.setOnDismissListener(new CorpusSelectorDismissListener());
471            mCorpusSelectionDialog.setOnCorpusSelectedListener(new CorpusSelectionListener());
472        }
473        mCorpusSelectionDialog.show(mCorpus);
474    }
475
476    protected boolean isCorpusSelectionDialogShowing() {
477        return mCorpusSelectionDialog != null && mCorpusSelectionDialog.isShowing();
478    }
479
480    protected void dismissCorpusSelectionDialog() {
481        if (mCorpusSelectionDialog != null) {
482            mCorpusSelectionDialog.dismiss();
483        }
484    }
485
486    /**
487     * @return true if a search was performed as a result of this click, false otherwise.
488     */
489    protected boolean onSearchClicked(int method) {
490        String query = getQuery();
491        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
492
493        // Don't do empty queries
494        if (TextUtils.getTrimmedLength(query) == 0) return false;
495
496        Corpus searchCorpus = getSearchCorpus();
497        if (searchCorpus == null) return false;
498
499        mTookAction = true;
500
501        // Log search start
502        getLogger().logSearch(mCorpus, method, query.length());
503
504        // Create shortcut
505        SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query);
506        if (searchShortcut != null) {
507            DataSuggestionCursor cursor = new DataSuggestionCursor(query);
508            cursor.add(searchShortcut);
509            getShortcutRepository().reportClick(cursor, 0);
510        }
511
512        // Start search
513        Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
514        launchIntent(intent);
515        return true;
516    }
517
518    protected void onVoiceSearchClicked() {
519        if (DBG) Log.d(TAG, "Voice Search clicked");
520        Corpus searchCorpus = getSearchCorpus();
521        if (searchCorpus == null) return;
522
523        mTookAction = true;
524
525        // Log voice search start
526        getLogger().logVoiceSearch(searchCorpus);
527
528        // Start voice search
529        Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData);
530        launchIntent(intent);
531    }
532
533    /**
534     * Gets the corpus to use for any searches. This is the web corpus in "All" mode,
535     * and the selected corpus otherwise.
536     */
537    private Corpus getSearchCorpus() {
538        if (mCorpus != null) {
539            return mCorpus;
540        } else {
541            Corpus webCorpus = getCorpora().getWebCorpus();
542            if (webCorpus == null) {
543                Log.e(TAG, "No web corpus");
544            }
545            return webCorpus;
546        }
547    }
548
549    protected SuggestionCursor getCurrentSuggestions() {
550        return mSuggestionsAdapter.getCurrentSuggestions();
551    }
552
553    protected void launchIntent(Intent intent) {
554        if (intent == null) {
555            return;
556        }
557        try {
558            startActivity(intent);
559        } catch (RuntimeException ex) {
560            // Since the intents for suggestions specified by suggestion providers,
561            // guard against them not being handled, not allowed, etc.
562            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
563        }
564    }
565
566    protected boolean launchSuggestion(int position) {
567        SuggestionCursor suggestions = getCurrentSuggestions();
568        if (position < 0 || position >= suggestions.getCount()) {
569            Log.w(TAG, "Tried to launch invalid suggestion " + position);
570            return false;
571        }
572
573        if (DBG) Log.d(TAG, "Launching suggestion " + position);
574        mTookAction = true;
575
576        // Log suggestion click
577        Collection<Corpus> corpora = mSuggestionsAdapter.getSuggestions().getIncludedCorpora();
578        getLogger().logSuggestionClick(position, suggestions, corpora);
579
580        // Create shortcut
581        getShortcutRepository().reportClick(suggestions, position);
582
583        // Launch intent
584        suggestions.moveTo(position);
585        Intent intent = suggestions.getSuggestionIntent(mAppSearchData);
586        launchIntent(intent);
587
588        return true;
589    }
590
591    protected boolean onSuggestionLongClicked(int position) {
592        if (DBG) Log.d(TAG, "Long clicked on suggestion " + position);
593        return false;
594    }
595
596    protected boolean onSuggestionKeyDown(int position, int keyCode, KeyEvent event) {
597        // Treat enter or search as a click
598        if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
599            return launchSuggestion(position);
600        }
601
602        if (keyCode == KeyEvent.KEYCODE_DPAD_UP && position == 0) {
603            // Moved up from the top suggestion, restore the user query and focus query box
604            if (DBG) Log.d(TAG, "Up and out");
605            restoreUserQuery();
606            return false;  // let the framework handle the move
607        }
608
609        if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
610                || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
611            // Moved left / right from a suggestion, keep current query, move
612            // focus to query box, and move cursor to far left / right
613            if (DBG) Log.d(TAG, "Left/right on a suggestion");
614            String query = getQuery();
615            int cursorPos = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? 0 : query.length();
616            mQueryTextView.setSelection(cursorPos);
617            mQueryTextView.requestFocus();
618            updateSuggestions(query);
619            return true;
620        }
621
622        return false;
623    }
624
625    protected void onSourceSelected() {
626        if (DBG) Log.d(TAG, "No suggestion selected");
627        restoreUserQuery();
628    }
629
630    protected int getSelectedPosition() {
631        return mSuggestionsView.getSelectedPosition();
632    }
633
634    /**
635     * Hides the input method.
636     */
637    protected void hideInputMethod() {
638        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
639        if (imm != null) {
640            imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0);
641        }
642    }
643
644    protected void showInputMethodForQuery() {
645        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
646        if (imm != null) {
647            imm.showSoftInput(mQueryTextView, 0);
648        }
649    }
650
651    /**
652     * Hides the input method when the suggestions get focus.
653     */
654    private class SuggestListFocusListener implements OnFocusChangeListener {
655        public void onFocusChange(View v, boolean focused) {
656            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
657            if (focused) {
658                // The suggestions list got focus, hide the input method
659                hideInputMethod();
660            }
661        }
662    }
663
664    private class QueryTextViewFocusListener implements OnFocusChangeListener {
665        public void onFocusChange(View v, boolean focused) {
666            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
667            if (focused) {
668                // The query box got focus, show the input method
669                showInputMethodForQuery();
670            }
671        }
672    }
673
674    private int getMaxSuggestions() {
675        Config config = getConfig();
676        return mCorpus == null
677                ? config.getMaxPromotedSuggestions()
678                : config.getMaxResultsPerSource();
679    }
680
681    private void updateSuggestionsBuffered() {
682        mHandler.removeCallbacks(mUpdateSuggestionsTask);
683        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
684        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
685    }
686
687    private void updateSuggestions(String query) {
688        // Log start latency if this is the first suggestions update
689        if (mStarting) {
690            mStarting = false;
691            String source = getIntent().getStringExtra(Search.SOURCE);
692            List<Corpus> rankedCorpora = getCorpusRanker().getRankedCorpora();
693            int latency = mStartLatencyTracker.getLatency();
694            getLogger().logStart(latency, source, mCorpus, rankedCorpora);
695        }
696
697        query = ltrim(query);
698        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
699                query, mCorpus, getMaxSuggestions());
700        mSuggestionsAdapter.setSuggestions(suggestions);
701    }
702
703    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
704        if (!event.isSystem() && !isDpadKey(keyCode)) {
705            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
706            if (mQueryTextView.requestFocus()) {
707                return mQueryTextView.dispatchKeyEvent(event);
708            }
709        }
710        return false;
711    }
712
713    private boolean isDpadKey(int keyCode) {
714        switch (keyCode) {
715            case KeyEvent.KEYCODE_DPAD_UP:
716            case KeyEvent.KEYCODE_DPAD_DOWN:
717            case KeyEvent.KEYCODE_DPAD_LEFT:
718            case KeyEvent.KEYCODE_DPAD_RIGHT:
719            case KeyEvent.KEYCODE_DPAD_CENTER:
720                return true;
721            default:
722                return false;
723        }
724    }
725
726    /**
727     * Filters the suggestions list when the search text changes.
728     */
729    private class SearchTextWatcher implements TextWatcher {
730        public void afterTextChanged(Editable s) {
731            boolean empty = s.length() == 0;
732            if (empty != mQueryWasEmpty) {
733                mQueryWasEmpty = empty;
734                updateUi(empty);
735            }
736            if (mUpdateSuggestions) {
737                String query = s == null ? "" : s.toString();
738                setUserQuery(query);
739                updateSuggestionsBuffered();
740            }
741        }
742
743        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
744        }
745
746        public void onTextChanged(CharSequence s, int start, int before, int count) {
747        }
748    }
749
750    /**
751     * Handles non-text keys in the query text view.
752     */
753    private class QueryTextViewKeyListener implements View.OnKeyListener {
754        public boolean onKey(View view, int keyCode, KeyEvent event) {
755            // Handle IME search action key
756            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
757                // if no action was taken, consume the key event so that the keyboard
758                // remains on screen.
759                return !onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
760            }
761            return false;
762        }
763    }
764
765    /**
766     * Handles key events on the search and voice search buttons,
767     * by refocusing to EditText.
768     */
769    private class ButtonsKeyListener implements View.OnKeyListener {
770        public boolean onKey(View v, int keyCode, KeyEvent event) {
771            return forwardKeyToQueryTextView(keyCode, event);
772        }
773    }
774
775    /**
776     * Handles key events on the suggestions list view.
777     */
778    private class SuggestionsViewKeyListener implements View.OnKeyListener {
779        public boolean onKey(View v, int keyCode, KeyEvent event) {
780            if (event.getAction() == KeyEvent.ACTION_DOWN) {
781                int position = getSelectedPosition();
782                if (onSuggestionKeyDown(position, keyCode, event)) {
783                        return true;
784                }
785            }
786            return forwardKeyToQueryTextView(keyCode, event);
787        }
788    }
789
790    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
791
792        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
793                int totalItemCount) {
794        }
795
796        public void onScrollStateChanged(AbsListView view, int scrollState) {
797            hideInputMethod();
798        }
799    }
800
801    private class ClickHandler implements SuggestionClickListener {
802       public void onSuggestionClicked(int position) {
803           launchSuggestion(position);
804       }
805
806       public boolean onSuggestionLongClicked(int position) {
807           return SearchActivity.this.onSuggestionLongClicked(position);
808       }
809
810       public void onSuggestionQueryRefineClicked(int position) {
811           if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
812           SuggestionCursor suggestions = getCurrentSuggestions();
813           if (suggestions != null) {
814               suggestions.moveTo(position);
815               String query = suggestions.getSuggestionQuery();
816               if (!TextUtils.isEmpty(query)) {
817                   query += " ";
818                   setUserQuery(query);
819                   setQuery(query, false);
820                   updateSuggestions(query);
821               }
822           }
823       }
824    }
825
826    private class SelectionHandler implements SuggestionSelectionListener {
827        public void onSuggestionSelected(int position) {
828            SuggestionCursor suggestions = getCurrentSuggestions();
829            suggestions.moveTo(position);
830            String displayQuery = suggestions.getSuggestionDisplayQuery();
831            if (TextUtils.isEmpty(displayQuery)) {
832                restoreUserQuery();
833            } else {
834                setQuery(displayQuery, false);
835            }
836        }
837
838        public void onNothingSelected() {
839                // This happens when a suggestion has been selected with the
840                // dpad / trackball and then a different UI element is touched.
841                // Do nothing, since we want to keep the query of the selection
842                // in the search box.
843        }
844    }
845
846    /**
847     * Listens for clicks on the source selector.
848     */
849    private class SearchGoButtonClickListener implements View.OnClickListener {
850        public void onClick(View view) {
851            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
852        }
853    }
854
855    /**
856     * Listens for clicks on the search button.
857     */
858    private class CorpusIndicatorClickListener implements View.OnClickListener {
859        public void onClick(View view) {
860            showCorpusSelectionDialog();
861        }
862    }
863
864    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
865        public void onDismiss(DialogInterface dialog) {
866            if (DBG) Log.d(TAG, "Corpus selector dismissed");
867            clearStartedIntoCorpusSelectionDialog();
868        }
869    }
870
871    private class CorpusSelectionListener
872            implements CorpusSelectionDialog.OnCorpusSelectedListener {
873        public void onCorpusSelected(String corpusName) {
874            setCorpus(corpusName);
875            updateSuggestions(getQuery());
876            mQueryTextView.requestFocus();
877            showInputMethodForQuery();
878        }
879    }
880
881    /**
882     * Listens for clicks on the voice search button.
883     */
884    private class VoiceSearchButtonClickListener implements View.OnClickListener {
885        public void onClick(View view) {
886            onVoiceSearchClicked();
887        }
888    }
889
890    private class CorporaObserver extends DataSetObserver {
891        @Override
892        public void onChanged() {
893            setCorpus(getCorpusName());
894            updateSuggestions(getQuery());
895        }
896    }
897
898    private static String ltrim(String text) {
899        int start = 0;
900        int length = text.length();
901        while (start < length && Character.isWhitespace(text.charAt(start))) {
902            start++;
903        }
904        return start > 0 ? text.substring(start, length) : text;
905    }
906
907}
908