SearchActivity.java revision fb8ce18922dae59db424fce906b5c113797fe81e
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.SearchActivityView;
21import com.android.quicksearchbox.ui.SuggestionClickListener;
22import com.android.quicksearchbox.ui.SuggestionsAdapter;
23import com.android.quicksearchbox.util.Consumer;
24import com.android.quicksearchbox.util.Consumers;
25import com.google.common.annotations.VisibleForTesting;
26import com.google.common.base.CharMatcher;
27
28import android.app.Activity;
29import android.app.SearchManager;
30import android.content.DialogInterface;
31import android.content.Intent;
32import android.database.DataSetObserver;
33import android.net.Uri;
34import android.os.Bundle;
35import android.os.Debug;
36import android.os.Handler;
37import android.text.TextUtils;
38import android.util.Log;
39import android.view.Menu;
40import android.view.View;
41import android.widget.Toast;
42
43import java.io.File;
44import java.util.ArrayList;
45import java.util.Collection;
46import java.util.List;
47import java.util.Set;
48
49/**
50 * The main activity for Quick Search Box. Shows the search UI.
51 *
52 */
53public class SearchActivity extends Activity {
54
55    private static final boolean DBG = false;
56    private static final String TAG = "QSB.SearchActivity";
57    private static final boolean TRACE = false;
58
59    private static final String SCHEME_CORPUS = "qsb.corpus";
60
61    public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS
62            = "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS";
63
64    // Keys for the saved instance state.
65    private static final String INSTANCE_KEY_CORPUS = "corpus";
66    private static final String INSTANCE_KEY_QUERY = "query";
67
68    // Measures time from for last onCreate()/onNewIntent() call.
69    private LatencyTracker mStartLatencyTracker;
70    // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
71    private boolean mStarting;
72    // True if the user has taken some action, e.g. launching a search, voice search,
73    // or suggestions, since QSB was last started.
74    private boolean mTookAction;
75
76    private SearchActivityView mSearchActivityView;
77
78    private CorporaObserver mCorporaObserver;
79
80    private Bundle mAppSearchData;
81
82    private final Handler mHandler = new Handler();
83    private final Runnable mUpdateSuggestionsTask = new Runnable() {
84        public void run() {
85            updateSuggestions();
86        }
87    };
88
89    private final Runnable mShowInputMethodTask = new Runnable() {
90        public void run() {
91            mSearchActivityView.showInputMethodForQuery();
92        }
93    };
94
95    private OnDestroyListener mDestroyListener;
96
97    /** Called when the activity is first created. */
98    @Override
99    public void onCreate(Bundle savedInstanceState) {
100        if (TRACE) startMethodTracing();
101        recordStartTime();
102        if (DBG) Log.d(TAG, "onCreate()");
103        super.onCreate(savedInstanceState);
104
105        mSearchActivityView = setupContentView();
106
107        if (getConfig().showScrollingSuggestions()) {
108            mSearchActivityView.setMaxPromotedSuggestions(getConfig().getMaxPromotedSuggestions());
109        } else {
110            mSearchActivityView.limitSuggestionsToViewHeight();
111        }
112        if (getConfig().showScrollingResults()) {
113            mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedSuggestions());
114        } else {
115            mSearchActivityView.limitResultsToViewHeight();
116        }
117
118        mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() {
119            public boolean onSearchClicked(int method) {
120                return SearchActivity.this.onSearchClicked(method);
121            }
122        });
123
124        mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() {
125            public void onQueryChanged() {
126                updateSuggestionsBuffered();
127            }
128        });
129
130        mSearchActivityView.setSuggestionClickListener(new ClickHandler());
131
132        mSearchActivityView.setSettingsButtonClickListener(new View.OnClickListener() {
133            public void onClick(View v) {
134                onSettingsClicked();
135            }
136        });
137
138        mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() {
139            public void onClick(View view) {
140                onVoiceSearchClicked();
141            }
142        });
143
144        mSearchActivityView.setExitClickListener(new View.OnClickListener() {
145            public void onClick(View v) {
146                finish();
147            }
148        });
149
150        // First get setup from intent
151        Intent intent = getIntent();
152        setupFromIntent(intent);
153        // Then restore any saved instance state
154        restoreInstanceState(savedInstanceState);
155
156        // Do this at the end, to avoid updating the list view when setSource()
157        // is called.
158        mSearchActivityView.start();
159
160        mCorporaObserver = new CorporaObserver();
161        getCorpora().registerDataSetObserver(mCorporaObserver);
162    }
163
164    protected SearchActivityView setupContentView() {
165        setContentView(R.layout.search_activity);
166        return (SearchActivityView) findViewById(R.id.search_activity_view);
167    }
168
169    protected SearchActivityView getSearchActivityView() {
170        return mSearchActivityView;
171    }
172
173    private void startMethodTracing() {
174        File traceDir = getDir("traces", 0);
175        String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath();
176        Debug.startMethodTracing(traceFile);
177    }
178
179    @Override
180    protected void onNewIntent(Intent intent) {
181        if (DBG) Log.d(TAG, "onNewIntent()");
182        recordStartTime();
183        setIntent(intent);
184        setupFromIntent(intent);
185    }
186
187    private void recordStartTime() {
188        mStartLatencyTracker = new LatencyTracker();
189        mStarting = true;
190        mTookAction = false;
191    }
192
193    protected void restoreInstanceState(Bundle savedInstanceState) {
194        if (savedInstanceState == null) return;
195        String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS);
196        String query = savedInstanceState.getString(INSTANCE_KEY_QUERY);
197        setCorpus(corpusName);
198        setQuery(query, false);
199    }
200
201    @Override
202    protected void onSaveInstanceState(Bundle outState) {
203        super.onSaveInstanceState(outState);
204        // We don't save appSearchData, since we always get the value
205        // from the intent and the user can't change it.
206
207        outState.putString(INSTANCE_KEY_CORPUS, getCorpusName());
208        outState.putString(INSTANCE_KEY_QUERY, getQuery());
209    }
210
211    private void setupFromIntent(Intent intent) {
212        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
213        String corpusName = getCorpusNameFromUri(intent.getData());
214        String query = intent.getStringExtra(SearchManager.QUERY);
215        Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
216        boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
217
218        setCorpus(corpusName);
219        setQuery(query, selectAll);
220        mAppSearchData = appSearchData;
221
222        if (startedIntoCorpusSelectionDialog()) {
223            mSearchActivityView.showCorpusSelectionDialog();
224        }
225    }
226
227    public boolean startedIntoCorpusSelectionDialog() {
228        return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction());
229    }
230
231    /**
232     * Removes corpus selector intent action, so that BACK works normally after
233     * dismissing and reopening the corpus selector.
234     */
235    public void clearStartedIntoCorpusSelectionDialog() {
236        Intent oldIntent = getIntent();
237        if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) {
238            Intent newIntent = new Intent(oldIntent);
239            newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH);
240            setIntent(newIntent);
241        }
242    }
243
244    public static Uri getCorpusUri(Corpus corpus) {
245        if (corpus == null) return null;
246        return new Uri.Builder()
247                .scheme(SCHEME_CORPUS)
248                .authority(corpus.getName())
249                .build();
250    }
251
252    private String getCorpusNameFromUri(Uri uri) {
253        if (uri == null) return null;
254        if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
255        return uri.getAuthority();
256    }
257
258    private Corpus getCorpus() {
259        return mSearchActivityView.getCorpus();
260    }
261
262    private String getCorpusName() {
263        return mSearchActivityView.getCorpusName();
264    }
265
266    private void setCorpus(String name) {
267        mSearchActivityView.setCorpus(name);
268    }
269
270    private QsbApplication getQsbApplication() {
271        return QsbApplication.get(this);
272    }
273
274    private Config getConfig() {
275        return getQsbApplication().getConfig();
276    }
277
278    protected SearchSettings getSettings() {
279        return getQsbApplication().getSettings();
280    }
281
282    private Corpora getCorpora() {
283        return getQsbApplication().getCorpora();
284    }
285
286    private CorpusRanker getCorpusRanker() {
287        return getQsbApplication().getCorpusRanker();
288    }
289
290    private ShortcutRepository getShortcutRepository() {
291        return getQsbApplication().getShortcutRepository();
292    }
293
294    private SuggestionsProvider getSuggestionsProvider() {
295        return getQsbApplication().getSuggestionsProvider();
296    }
297
298    private Logger getLogger() {
299        return getQsbApplication().getLogger();
300    }
301
302    @VisibleForTesting
303    public void setOnDestroyListener(OnDestroyListener l) {
304        mDestroyListener = l;
305    }
306
307    @Override
308    protected void onDestroy() {
309        if (DBG) Log.d(TAG, "onDestroy()");
310        getCorpora().unregisterDataSetObserver(mCorporaObserver);
311        mSearchActivityView.destroy();
312        super.onDestroy();
313        if (mDestroyListener != null) {
314            mDestroyListener.onDestroyed();
315        }
316    }
317
318    @Override
319    protected void onStop() {
320        if (DBG) Log.d(TAG, "onStop()");
321        if (!mTookAction) {
322            // TODO: This gets logged when starting other activities, e.g. by opening the search
323            // settings, or clicking a notification in the status bar.
324            // TODO we should log both sets of suggestions in 2-pane mode
325            getLogger().logExit(getCurrentSuggestions(), getQuery().length());
326        }
327        // Close all open suggestion cursors. The query will be redone in onResume()
328        // if we come back to this activity.
329        mSearchActivityView.clearSuggestions();
330        getQsbApplication().getShortcutRefresher().reset();
331        mSearchActivityView.onStop();
332        super.onStop();
333    }
334
335    @Override
336    protected void onRestart() {
337        if (DBG) Log.d(TAG, "onRestart()");
338        super.onRestart();
339    }
340
341    @Override
342    protected void onResume() {
343        if (DBG) Log.d(TAG, "onResume()");
344        super.onResume();
345        updateSuggestionsBuffered();
346        mSearchActivityView.onResume();
347        if (TRACE) Debug.stopMethodTracing();
348    }
349
350    @Override
351    public boolean onCreateOptionsMenu(Menu menu) {
352        super.onCreateOptionsMenu(menu);
353        getSettings().addMenuItems(menu);
354        return true;
355    }
356
357    @Override
358    public boolean onPrepareOptionsMenu(Menu menu) {
359        super.onPrepareOptionsMenu(menu);
360        getSettings().updateMenuItems(menu);
361        return true;
362    }
363
364    @Override
365    public void onWindowFocusChanged(boolean hasFocus) {
366        super.onWindowFocusChanged(hasFocus);
367        if (hasFocus) {
368            // Launch the IME after a bit
369            mHandler.postDelayed(mShowInputMethodTask, 0);
370        }
371    }
372
373    protected String getQuery() {
374        return mSearchActivityView.getQuery();
375    }
376
377    protected void setQuery(String query, boolean selectAll) {
378        mSearchActivityView.setQuery(query, selectAll);
379    }
380
381    public CorpusSelectionDialog getCorpusSelectionDialog() {
382        CorpusSelectionDialog dialog = createCorpusSelectionDialog();
383        dialog.setOwnerActivity(this);
384        dialog.setOnDismissListener(new CorpusSelectorDismissListener());
385        return dialog;
386    }
387
388    protected CorpusSelectionDialog createCorpusSelectionDialog() {
389        return new CorpusSelectionDialog(this, getSettings());
390    }
391
392    /**
393     * @return true if a search was performed as a result of this click, false otherwise.
394     */
395    protected boolean onSearchClicked(int method) {
396        String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
397        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
398
399        // Don't do empty queries
400        if (TextUtils.getTrimmedLength(query) == 0) return false;
401
402        Corpus searchCorpus = getSearchCorpus();
403        if (searchCorpus == null) return false;
404
405        mTookAction = true;
406
407        // Log search start
408        getLogger().logSearch(getCorpus(), method, query.length());
409
410        // Create shortcut
411        SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query);
412        if (searchShortcut != null) {
413            ListSuggestionCursor cursor = new ListSuggestionCursor(query);
414            cursor.add(searchShortcut);
415            getShortcutRepository().reportClick(cursor, 0);
416        }
417
418        // Start search
419        startSearch(searchCorpus, query);
420        return true;
421    }
422
423    protected void startSearch(Corpus searchCorpus, String query) {
424        Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
425        launchIntent(intent);
426    }
427
428    protected void onVoiceSearchClicked() {
429        if (DBG) Log.d(TAG, "Voice Search clicked");
430        Corpus searchCorpus = getSearchCorpus();
431        if (searchCorpus == null) return;
432
433        mTookAction = true;
434
435        // Log voice search start
436        getLogger().logVoiceSearch(searchCorpus);
437
438        // Start voice search
439        Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData);
440        launchIntent(intent);
441    }
442
443    protected void onSettingsClicked() {
444        startActivity(getSettings().getSearchSettingsIntent());
445    }
446
447    protected Corpus getSearchCorpus() {
448        return mSearchActivityView.getSearchCorpus();
449    }
450
451    protected SuggestionCursor getCurrentSuggestions() {
452        return mSearchActivityView.getCurrentSuggestions();
453    }
454
455    protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) {
456        SuggestionCursor suggestions = adapter.getCurrentSuggestions();
457        if (suggestions == null) {
458            return null;
459        }
460        int count = suggestions.getCount();
461        if (position < 0 || position >= count) {
462            Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
463            return null;
464        }
465        suggestions.moveTo(position);
466        return suggestions;
467    }
468
469    protected Set<Corpus> getCurrentIncludedCorpora() {
470        Suggestions suggestions = mSearchActivityView.getSuggestions();
471        return suggestions == null  ? null : suggestions.getIncludedCorpora();
472    }
473
474    protected void launchIntent(Intent intent) {
475        if (DBG) Log.d(TAG, "launchIntent " + intent);
476        if (intent == null) {
477            return;
478        }
479        try {
480            startActivity(intent);
481            if (!getConfig().keepSearchActivityInBackStack()) {
482                finish();
483            }
484        } catch (RuntimeException ex) {
485            // Since the intents for suggestions specified by suggestion providers,
486            // guard against them not being handled, not allowed, etc.
487            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
488        }
489    }
490
491    private boolean launchSuggestion(SuggestionsAdapter adapter, int position) {
492        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
493        if (suggestions == null) return false;
494
495        if (DBG) Log.d(TAG, "Launching suggestion " + position);
496        mTookAction = true;
497
498        // Log suggestion click
499        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
500                Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
501
502        // Create shortcut
503        getShortcutRepository().reportClick(suggestions, position);
504
505        // Launch intent
506        launchSuggestion(suggestions, position);
507
508        return true;
509    }
510
511    protected void launchSuggestion(SuggestionCursor suggestions, int position) {
512        suggestions.moveTo(position);
513        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
514        launchIntent(intent);
515    }
516
517    protected void removeFromHistory(SuggestionsAdapter adapter, int position) {
518        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
519        if (suggestions == null) return;
520        removeFromHistory(suggestions, position);
521        // TODO: Log to event log?
522    }
523
524    protected void removeFromHistory(SuggestionCursor suggestions, int position) {
525        removeShortcut(suggestions, position);
526        removeFromHistoryDone(true);
527    }
528
529    protected void removeFromHistoryDone(boolean ok) {
530        Log.i(TAG, "Removed query from history, success=" + ok);
531        updateSuggestionsBuffered();
532        if (!ok) {
533            Toast.makeText(this, R.string.remove_from_history_failed, Toast.LENGTH_SHORT).show();
534        }
535    }
536
537    protected void removeShortcut(SuggestionCursor suggestions, int position) {
538        if (suggestions.isSuggestionShortcut()) {
539            if (DBG) Log.d(TAG, "Removing suggestion " + position + " from shortcuts");
540            getShortcutRepository().removeFromHistory(suggestions, position);
541        }
542    }
543
544    protected void clickedQuickContact(SuggestionsAdapter adapter, int position) {
545        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
546        if (suggestions == null) return;
547
548        if (DBG) Log.d(TAG, "Used suggestion " + position);
549        mTookAction = true;
550
551        // Log suggestion click
552        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
553                Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
554
555        // Create shortcut
556        getShortcutRepository().reportClick(suggestions, position);
557    }
558
559    protected void refineSuggestion(SuggestionsAdapter adapter, int position) {
560        if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
561        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
562        if (suggestions == null) {
563            return;
564        }
565        String query = suggestions.getSuggestionQuery();
566        if (TextUtils.isEmpty(query)) {
567            return;
568        }
569
570        // Log refine click
571        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
572                Logger.SUGGESTION_CLICK_TYPE_REFINE);
573
574        // Put query + space in query text view
575        String queryWithSpace = query + ' ';
576        setQuery(queryWithSpace, false);
577        updateSuggestions();
578        mSearchActivityView.focusQueryTextView();
579    }
580
581    private void updateSuggestionsBuffered() {
582        if (DBG) Log.d(TAG, "updateSuggestionsBuffered()");
583        mHandler.removeCallbacks(mUpdateSuggestionsTask);
584        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
585        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
586    }
587
588    private void gotSuggestions(Suggestions suggestions) {
589        if (mStarting) {
590            mStarting = false;
591            String source = getIntent().getStringExtra(Search.SOURCE);
592            int latency = mStartLatencyTracker.getLatency();
593            getLogger().logStart(latency, source, getCorpus(),
594                    suggestions == null ? null : suggestions.getExpectedCorpora());
595            getQsbApplication().onStartupComplete();
596        }
597    }
598
599    private void getCorporaToQuery(Consumer<List<Corpus>> consumer) {
600        Corpus corpus = getCorpus();
601        if (corpus == null) {
602            getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer));
603        } else {
604            List<Corpus> corpora = new ArrayList<Corpus>();
605            Corpus searchCorpus = getSearchCorpus();
606            if (searchCorpus != null) corpora.add(searchCorpus);
607            consumer.consume(corpora);
608        }
609    }
610
611    protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
612            final Suggestions suggestions) {
613        ShortcutRepository shortcutRepo = getShortcutRepository();
614        if (shortcutRepo == null) return;
615        if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) {
616            return;
617        }
618        Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
619                new Consumer<ShortcutCursor>() {
620            public boolean consume(ShortcutCursor shortcuts) {
621                suggestions.setShortcuts(shortcuts);
622                return true;
623            }
624        });
625        shortcutRepo.getShortcutsForQuery(query, corporaToQuery,
626                getSettings().allowWebSearchShortcuts(), consumer);
627    }
628
629    public void updateSuggestions() {
630        if (DBG) Log.d(TAG, "updateSuggestions()");
631        final String query = CharMatcher.WHITESPACE.trimLeadingFrom(getQuery());
632        getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
633        getCorporaToQuery(new Consumer<List<Corpus>>(){
634            @Override
635            public boolean consume(List<Corpus> corporaToQuery) {
636                updateSuggestions(query, corporaToQuery);
637                return true;
638            }
639        });
640    }
641
642    protected void updateSuggestions(String query, List<Corpus> corporaToQuery) {
643        if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + corporaToQuery + ")");
644        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
645                query, corporaToQuery);
646        getShortcutsForQuery(query, corporaToQuery, suggestions);
647
648        // Log start latency if this is the first suggestions update
649        gotSuggestions(suggestions);
650
651        showSuggestions(suggestions);
652    }
653
654    protected void showSuggestions(Suggestions suggestions) {
655        mSearchActivityView.setSuggestions(suggestions);
656    }
657
658    private class ClickHandler implements SuggestionClickListener {
659
660        public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) {
661            clickedQuickContact(adapter, position);
662        }
663
664        public void onSuggestionClicked(SuggestionsAdapter adapter, int position) {
665            launchSuggestion(adapter, position);
666        }
667
668        public void onSuggestionRemoveFromHistoryClicked(SuggestionsAdapter adapter, int position) {
669            removeFromHistory(adapter, position);
670        }
671
672        public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) {
673            refineSuggestion(adapter, position);
674        }
675    }
676
677    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
678        public void onDismiss(DialogInterface dialog) {
679            if (DBG) Log.d(TAG, "Corpus selector dismissed");
680            clearStartedIntoCorpusSelectionDialog();
681        }
682    }
683
684    private class CorporaObserver extends DataSetObserver {
685        @Override
686        public void onChanged() {
687            setCorpus(getCorpusName());
688            updateSuggestions();
689        }
690    }
691
692    public interface OnDestroyListener {
693        void onDestroyed();
694    }
695
696}
697