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