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