SearchActivity.java revision 9e46c057d7f88d2a899e124f96d266c54250bb45
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        mSearchActivityView.setExitClickListener(new View.OnClickListener() {
134            public void onClick(View v) {
135                finish();
136            }
137        });
138
139        // First get setup from intent
140        Intent intent = getIntent();
141        setupFromIntent(intent);
142        // Then restore any saved instance state
143        restoreInstanceState(savedInstanceState);
144
145        // Do this at the end, to avoid updating the list view when setSource()
146        // is called.
147        mSearchActivityView.start();
148
149        mCorporaObserver = new CorporaObserver();
150        getCorpora().registerDataSetObserver(mCorporaObserver);
151    }
152
153    protected SearchActivityView setupContentView() {
154        setContentView(R.layout.search_activity);
155        return (SearchActivityView) findViewById(R.id.search_activity_view);
156    }
157
158    protected SearchActivityView getSearchActivityView() {
159        return mSearchActivityView;
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            mSearchActivityView.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    public 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() {
248        return mSearchActivityView.getCorpus();
249    }
250
251    private String getCorpusName() {
252        return mSearchActivityView.getCorpusName();
253    }
254
255    private void setCorpus(String name) {
256        mSearchActivityView.setCorpus(name);
257    }
258
259    private QsbApplication getQsbApplication() {
260        return QsbApplication.get(this);
261    }
262
263    private Config getConfig() {
264        return getQsbApplication().getConfig();
265    }
266
267    protected SearchSettings getSettings() {
268        return getQsbApplication().getSettings();
269    }
270
271    private Corpora getCorpora() {
272        return getQsbApplication().getCorpora();
273    }
274
275    private CorpusRanker getCorpusRanker() {
276        return getQsbApplication().getCorpusRanker();
277    }
278
279    private ShortcutRepository getShortcutRepository() {
280        return getQsbApplication().getShortcutRepository();
281    }
282
283    private SuggestionsProvider getSuggestionsProvider() {
284        return getQsbApplication().getSuggestionsProvider();
285    }
286
287    private Logger getLogger() {
288        return getQsbApplication().getLogger();
289    }
290
291    @VisibleForTesting
292    public void setOnDestroyListener(OnDestroyListener l) {
293        mDestroyListener = l;
294    }
295
296    @Override
297    protected void onDestroy() {
298        if (DBG) Log.d(TAG, "onDestroy()");
299        getCorpora().unregisterDataSetObserver(mCorporaObserver);
300        mSearchActivityView.destroy();
301        super.onDestroy();
302        if (mDestroyListener != null) {
303            mDestroyListener.onDestroyed();
304        }
305    }
306
307    @Override
308    protected void onStop() {
309        if (DBG) Log.d(TAG, "onStop()");
310        if (!mTookAction) {
311            // TODO: This gets logged when starting other activities, e.g. by opening the search
312            // settings, or clicking a notification in the status bar.
313            // TODO we should log both sets of suggestions in 2-pane mode
314            getLogger().logExit(getCurrentSuggestions(), getQuery().length());
315        }
316        // Close all open suggestion cursors. The query will be redone in onResume()
317        // if we come back to this activity.
318        mSearchActivityView.clearSuggestions();
319        getQsbApplication().getShortcutRefresher().reset();
320        mSearchActivityView.onStop();
321        super.onStop();
322    }
323
324    @Override
325    protected void onRestart() {
326        if (DBG) Log.d(TAG, "onRestart()");
327        super.onRestart();
328    }
329
330    @Override
331    protected void onResume() {
332        if (DBG) Log.d(TAG, "onResume()");
333        super.onResume();
334        updateSuggestionsBuffered();
335        mSearchActivityView.onResume();
336        if (TRACE) Debug.stopMethodTracing();
337    }
338
339    @Override
340    public boolean onCreateOptionsMenu(Menu menu) {
341        super.onCreateOptionsMenu(menu);
342        getSettings().addMenuItems(menu);
343        return true;
344    }
345
346    @Override
347    public boolean onPrepareOptionsMenu(Menu menu) {
348        super.onPrepareOptionsMenu(menu);
349        getSettings().updateMenuItems(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    public CorpusSelectionDialog getCorpusSelectionDialog() {
371        CorpusSelectionDialog dialog = createCorpusSelectionDialog();
372        dialog.setOwnerActivity(this);
373        dialog.setOnDismissListener(new CorpusSelectorDismissListener());
374        return dialog;
375    }
376
377    protected CorpusSelectionDialog createCorpusSelectionDialog() {
378        return new CorpusSelectionDialog(this, getSettings());
379    }
380
381    /**
382     * @return true if a search was performed as a result of this click, false otherwise.
383     */
384    protected boolean onSearchClicked(int method) {
385        String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
386        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
387
388        // Don't do empty queries
389        if (TextUtils.getTrimmedLength(query) == 0) return false;
390
391        Corpus searchCorpus = getSearchCorpus();
392        if (searchCorpus == null) return false;
393
394        mTookAction = true;
395
396        // Log search start
397        getLogger().logSearch(getCorpus(), method, query.length());
398
399        // Create shortcut
400        SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query);
401        if (searchShortcut != null) {
402            ListSuggestionCursor cursor = new ListSuggestionCursor(query);
403            cursor.add(searchShortcut);
404            getShortcutRepository().reportClick(cursor, 0);
405        }
406
407        // Start search
408        startSearch(searchCorpus, query);
409        return true;
410    }
411
412    protected void startSearch(Corpus searchCorpus, String query) {
413        Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
414        launchIntent(intent);
415    }
416
417    protected void onVoiceSearchClicked() {
418        if (DBG) Log.d(TAG, "Voice Search clicked");
419        Corpus searchCorpus = getSearchCorpus();
420        if (searchCorpus == null) return;
421
422        mTookAction = true;
423
424        // Log voice search start
425        getLogger().logVoiceSearch(searchCorpus);
426
427        // Start voice search
428        Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData);
429        launchIntent(intent);
430    }
431
432    protected void onSettingsClicked() {
433        startActivity(getSettings().getSearchSettingsIntent());
434    }
435
436    protected Corpus getSearchCorpus() {
437        return mSearchActivityView.getSearchCorpus();
438    }
439
440    protected SuggestionCursor getCurrentSuggestions() {
441        return mSearchActivityView.getCurrentSuggestions();
442    }
443
444    protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) {
445        SuggestionCursor suggestions = adapter.getCurrentSuggestions();
446        if (suggestions == null) {
447            return null;
448        }
449        int count = suggestions.getCount();
450        if (position < 0 || position >= count) {
451            Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
452            return null;
453        }
454        suggestions.moveTo(position);
455        return suggestions;
456    }
457
458    protected Set<Corpus> getCurrentIncludedCorpora() {
459        Suggestions suggestions = mSearchActivityView.getSuggestions();
460        return suggestions == null  ? null : suggestions.getIncludedCorpora();
461    }
462
463    protected void launchIntent(Intent intent) {
464        if (DBG) Log.d(TAG, "launchIntent " + intent);
465        if (intent == null) {
466            return;
467        }
468        try {
469            startActivity(intent);
470        } catch (RuntimeException ex) {
471            // Since the intents for suggestions specified by suggestion providers,
472            // guard against them not being handled, not allowed, etc.
473            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
474        }
475    }
476
477    private boolean launchSuggestion(SuggestionsAdapter adapter, int position) {
478        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
479        if (suggestions == null) return false;
480
481        if (DBG) Log.d(TAG, "Launching suggestion " + position);
482        mTookAction = true;
483
484        // Log suggestion click
485        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
486                Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
487
488        // Create shortcut
489        getShortcutRepository().reportClick(suggestions, position);
490
491        // Launch intent
492        launchSuggestion(suggestions, position);
493
494        return true;
495    }
496
497    protected void launchSuggestion(SuggestionCursor suggestions, int position) {
498        suggestions.moveTo(position);
499        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
500        launchIntent(intent);
501    }
502
503    protected void clickedQuickContact(SuggestionsAdapter adapter, int position) {
504        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
505        if (suggestions == null) return;
506
507        if (DBG) Log.d(TAG, "Used suggestion " + position);
508        mTookAction = true;
509
510        // Log suggestion click
511        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
512                Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
513
514        // Create shortcut
515        getShortcutRepository().reportClick(suggestions, position);
516    }
517
518    protected void refineSuggestion(SuggestionsAdapter adapter, int position) {
519        if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
520        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
521        if (suggestions == null) {
522            return;
523        }
524        String query = suggestions.getSuggestionQuery();
525        if (TextUtils.isEmpty(query)) {
526            return;
527        }
528
529        // Log refine click
530        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
531                Logger.SUGGESTION_CLICK_TYPE_REFINE);
532
533        // Put query + space in query text view
534        String queryWithSpace = query + ' ';
535        setQuery(queryWithSpace, false);
536        updateSuggestions(queryWithSpace);
537        mSearchActivityView.focusQueryTextView();
538    }
539
540    protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
541        if (DBG) Log.d(TAG, "Long clicked on suggestion " + position);
542        return false;
543    }
544
545    private void updateSuggestionsBuffered() {
546        mHandler.removeCallbacks(mUpdateSuggestionsTask);
547        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
548        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
549    }
550
551    private void gotSuggestions(Suggestions suggestions) {
552        if (mStarting) {
553            mStarting = false;
554            String source = getIntent().getStringExtra(Search.SOURCE);
555            int latency = mStartLatencyTracker.getLatency();
556            getLogger().logStart(latency, source, getCorpus(),
557                    suggestions == null ? null : suggestions.getExpectedCorpora());
558            getQsbApplication().onStartupComplete();
559        }
560    }
561
562    private void getCorporaToQuery(Consumer<List<Corpus>> consumer) {
563        // Always query all corpora, so that all corpus result counts are valid
564        getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer));
565    }
566
567    protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
568            final Suggestions suggestions) {
569        ShortcutRepository shortcutRepo = getShortcutRepository();
570        if (shortcutRepo == null) return;
571        Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
572                new Consumer<ShortcutCursor>() {
573            public boolean consume(ShortcutCursor shortcuts) {
574                suggestions.setShortcuts(shortcuts);
575                return true;
576            }
577        });
578        shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer);
579    }
580
581    public void updateSuggestions(String untrimmedQuery) {
582        final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery);
583        if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + getCorpus() + ")");
584        getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
585        getCorporaToQuery(new Consumer<List<Corpus>>(){
586            @Override
587            public boolean consume(List<Corpus> corporaToQuery) {
588                updateSuggestions(query, corporaToQuery);
589                return true;
590            }
591        });
592    }
593
594    protected void updateSuggestions(String query, List<Corpus> corporaToQuery) {
595        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
596                query, corporaToQuery);
597        getShortcutsForQuery(query, corporaToQuery, suggestions);
598
599        // Log start latency if this is the first suggestions update
600        gotSuggestions(suggestions);
601
602        showSuggestions(suggestions);
603    }
604
605    protected void showSuggestions(Suggestions suggestions) {
606        mSearchActivityView.setSuggestions(suggestions);
607    }
608
609    private class ClickHandler implements SuggestionClickListener {
610
611        public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) {
612            clickedQuickContact(adapter, position);
613        }
614
615        public void onSuggestionClicked(SuggestionsAdapter adapter, int position) {
616            launchSuggestion(adapter, position);
617        }
618
619        public boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
620            return SearchActivity.this.onSuggestionLongClicked(adapter, position);
621        }
622
623        public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) {
624            refineSuggestion(adapter, position);
625        }
626    }
627
628    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
629        public void onDismiss(DialogInterface dialog) {
630            if (DBG) Log.d(TAG, "Corpus selector dismissed");
631            clearStartedIntoCorpusSelectionDialog();
632        }
633    }
634
635    private class CorporaObserver extends DataSetObserver {
636        @Override
637        public void onChanged() {
638            setCorpus(getCorpusName());
639            updateSuggestions(getQuery());
640        }
641    }
642
643    public interface OnDestroyListener {
644        void onDestroyed();
645    }
646
647}
648