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