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