SearchActivity.java revision 0fe82998ab9d499e99722b199f5e0db9fc12e433
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        if (getConfig().showScrollingSuggestions()) {
106            mSearchActivityView.setMaxPromotedSuggestions(getConfig().getMaxPromotedSuggestions());
107        } else {
108            mSearchActivityView.limitSuggestionsToViewHeight();
109        }
110        if (getConfig().showScrollingResults()) {
111            mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedSuggestions());
112        } else {
113            mSearchActivityView.limitResultsToViewHeight();
114        }
115
116        mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() {
117            public boolean onSearchClicked(int method) {
118                return SearchActivity.this.onSearchClicked(method);
119            }
120        });
121
122        mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() {
123            public void onQueryChanged() {
124                updateSuggestionsBuffered();
125            }
126        });
127
128        mSearchActivityView.setSuggestionClickListener(new ClickHandler());
129
130        mSearchActivityView.setSettingsButtonClickListener(new View.OnClickListener() {
131            public void onClick(View v) {
132                onSettingsClicked();
133            }
134        });
135
136        mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() {
137            public void onClick(View view) {
138                onVoiceSearchClicked();
139            }
140        });
141
142        mSearchActivityView.setExitClickListener(new View.OnClickListener() {
143            public void onClick(View v) {
144                finish();
145            }
146        });
147
148        // First get setup from intent
149        Intent intent = getIntent();
150        setupFromIntent(intent);
151        // Then restore any saved instance state
152        restoreInstanceState(savedInstanceState);
153
154        // Do this at the end, to avoid updating the list view when setSource()
155        // is called.
156        mSearchActivityView.start();
157
158        mCorporaObserver = new CorporaObserver();
159        getCorpora().registerDataSetObserver(mCorporaObserver);
160    }
161
162    protected SearchActivityView setupContentView() {
163        setContentView(R.layout.search_activity);
164        return (SearchActivityView) findViewById(R.id.search_activity_view);
165    }
166
167    protected SearchActivityView getSearchActivityView() {
168        return mSearchActivityView;
169    }
170
171    private void startMethodTracing() {
172        File traceDir = getDir("traces", 0);
173        String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath();
174        Debug.startMethodTracing(traceFile);
175    }
176
177    @Override
178    protected void onNewIntent(Intent intent) {
179        if (DBG) Log.d(TAG, "onNewIntent()");
180        recordStartTime();
181        setIntent(intent);
182        setupFromIntent(intent);
183    }
184
185    private void recordStartTime() {
186        mStartLatencyTracker = new LatencyTracker();
187        mStarting = true;
188        mTookAction = false;
189    }
190
191    protected void restoreInstanceState(Bundle savedInstanceState) {
192        if (savedInstanceState == null) return;
193        String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS);
194        String query = savedInstanceState.getString(INSTANCE_KEY_QUERY);
195        setCorpus(corpusName);
196        setQuery(query, false);
197    }
198
199    @Override
200    protected void onSaveInstanceState(Bundle outState) {
201        super.onSaveInstanceState(outState);
202        // We don't save appSearchData, since we always get the value
203        // from the intent and the user can't change it.
204
205        outState.putString(INSTANCE_KEY_CORPUS, getCorpusName());
206        outState.putString(INSTANCE_KEY_QUERY, getQuery());
207    }
208
209    private void setupFromIntent(Intent intent) {
210        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
211        String corpusName = getCorpusNameFromUri(intent.getData());
212        String query = intent.getStringExtra(SearchManager.QUERY);
213        Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
214        boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
215
216        setCorpus(corpusName);
217        setQuery(query, selectAll);
218        mAppSearchData = appSearchData;
219
220        if (startedIntoCorpusSelectionDialog()) {
221            mSearchActivityView.showCorpusSelectionDialog();
222        }
223    }
224
225    public boolean startedIntoCorpusSelectionDialog() {
226        return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction());
227    }
228
229    /**
230     * Removes corpus selector intent action, so that BACK works normally after
231     * dismissing and reopening the corpus selector.
232     */
233    public void clearStartedIntoCorpusSelectionDialog() {
234        Intent oldIntent = getIntent();
235        if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) {
236            Intent newIntent = new Intent(oldIntent);
237            newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH);
238            setIntent(newIntent);
239        }
240    }
241
242    public static Uri getCorpusUri(Corpus corpus) {
243        if (corpus == null) return null;
244        return new Uri.Builder()
245                .scheme(SCHEME_CORPUS)
246                .authority(corpus.getName())
247                .build();
248    }
249
250    private String getCorpusNameFromUri(Uri uri) {
251        if (uri == null) return null;
252        if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
253        return uri.getAuthority();
254    }
255
256    private Corpus getCorpus() {
257        return mSearchActivityView.getCorpus();
258    }
259
260    private String getCorpusName() {
261        return mSearchActivityView.getCorpusName();
262    }
263
264    private void setCorpus(String name) {
265        mSearchActivityView.setCorpus(name);
266    }
267
268    private QsbApplication getQsbApplication() {
269        return QsbApplication.get(this);
270    }
271
272    private Config getConfig() {
273        return getQsbApplication().getConfig();
274    }
275
276    protected SearchSettings getSettings() {
277        return getQsbApplication().getSettings();
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        mSearchActivityView.onStop();
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        mSearchActivityView.onResume();
345        if (TRACE) Debug.stopMethodTracing();
346    }
347
348    @Override
349    public boolean onCreateOptionsMenu(Menu menu) {
350        super.onCreateOptionsMenu(menu);
351        getSettings().addMenuItems(menu);
352        return true;
353    }
354
355    @Override
356    public boolean onPrepareOptionsMenu(Menu menu) {
357        super.onPrepareOptionsMenu(menu);
358        getSettings().updateMenuItems(menu);
359        return true;
360    }
361
362    @Override
363    public void onWindowFocusChanged(boolean hasFocus) {
364        super.onWindowFocusChanged(hasFocus);
365        if (hasFocus) {
366            // Launch the IME after a bit
367            mHandler.postDelayed(mShowInputMethodTask, 0);
368        }
369    }
370
371    protected String getQuery() {
372        return mSearchActivityView.getQuery();
373    }
374
375    protected void setQuery(String query, boolean selectAll) {
376        mSearchActivityView.setQuery(query, selectAll);
377    }
378
379    public CorpusSelectionDialog getCorpusSelectionDialog() {
380        CorpusSelectionDialog dialog = createCorpusSelectionDialog();
381        dialog.setOwnerActivity(this);
382        dialog.setOnDismissListener(new CorpusSelectorDismissListener());
383        return dialog;
384    }
385
386    protected CorpusSelectionDialog createCorpusSelectionDialog() {
387        return new CorpusSelectionDialog(this, getSettings());
388    }
389
390    /**
391     * @return true if a search was performed as a result of this click, false otherwise.
392     */
393    protected boolean onSearchClicked(int method) {
394        String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
395        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
396
397        // Don't do empty queries
398        if (TextUtils.getTrimmedLength(query) == 0) return false;
399
400        Corpus searchCorpus = getSearchCorpus();
401        if (searchCorpus == null) return false;
402
403        mTookAction = true;
404
405        // Log search start
406        getLogger().logSearch(getCorpus(), method, query.length());
407
408        // Create shortcut
409        SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query);
410        if (searchShortcut != null) {
411            ListSuggestionCursor cursor = new ListSuggestionCursor(query);
412            cursor.add(searchShortcut);
413            getShortcutRepository().reportClick(cursor, 0);
414        }
415
416        // Start search
417        startSearch(searchCorpus, query);
418        return true;
419    }
420
421    protected void startSearch(Corpus searchCorpus, String query) {
422        Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
423        launchIntent(intent);
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        startActivity(getSettings().getSearchSettingsIntent());
443    }
444
445    protected Corpus getSearchCorpus() {
446        return mSearchActivityView.getSearchCorpus();
447    }
448
449    protected SuggestionCursor getCurrentSuggestions() {
450        return mSearchActivityView.getCurrentSuggestions();
451    }
452
453    protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) {
454        SuggestionCursor suggestions = adapter.getCurrentSuggestions();
455        if (suggestions == null) {
456            return null;
457        }
458        int count = suggestions.getCount();
459        if (position < 0 || position >= count) {
460            Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
461            return null;
462        }
463        suggestions.moveTo(position);
464        return suggestions;
465    }
466
467    protected Set<Corpus> getCurrentIncludedCorpora() {
468        Suggestions suggestions = mSearchActivityView.getSuggestions();
469        return suggestions == null  ? null : suggestions.getIncludedCorpora();
470    }
471
472    protected void launchIntent(Intent intent) {
473        if (DBG) Log.d(TAG, "launchIntent " + intent);
474        if (intent == null) {
475            return;
476        }
477        try {
478            startActivity(intent);
479            if (!getConfig().keepSearchActivityInBackStack()) {
480                finish();
481            }
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        launchSuggestion(suggestions, position);
505
506        return true;
507    }
508
509    protected void launchSuggestion(SuggestionCursor suggestions, int position) {
510        suggestions.moveTo(position);
511        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
512        launchIntent(intent);
513    }
514
515    protected void clickedQuickContact(SuggestionsAdapter adapter, int position) {
516        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
517        if (suggestions == null) return;
518
519        if (DBG) Log.d(TAG, "Used suggestion " + position);
520        mTookAction = true;
521
522        // Log suggestion click
523        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
524                Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
525
526        // Create shortcut
527        getShortcutRepository().reportClick(suggestions, position);
528    }
529
530    protected void refineSuggestion(SuggestionsAdapter adapter, int position) {
531        if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
532        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
533        if (suggestions == null) {
534            return;
535        }
536        String query = suggestions.getSuggestionQuery();
537        if (TextUtils.isEmpty(query)) {
538            return;
539        }
540
541        // Log refine click
542        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
543                Logger.SUGGESTION_CLICK_TYPE_REFINE);
544
545        // Put query + space in query text view
546        String queryWithSpace = query + ' ';
547        setQuery(queryWithSpace, false);
548        updateSuggestions(queryWithSpace);
549        mSearchActivityView.focusQueryTextView();
550    }
551
552    protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
553        if (DBG) Log.d(TAG, "Long clicked on suggestion " + position);
554        return false;
555    }
556
557    private void updateSuggestionsBuffered() {
558        mHandler.removeCallbacks(mUpdateSuggestionsTask);
559        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
560        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
561    }
562
563    private void gotSuggestions(Suggestions suggestions) {
564        if (mStarting) {
565            mStarting = false;
566            String source = getIntent().getStringExtra(Search.SOURCE);
567            int latency = mStartLatencyTracker.getLatency();
568            getLogger().logStart(latency, source, getCorpus(),
569                    suggestions == null ? null : suggestions.getExpectedCorpora());
570            getQsbApplication().onStartupComplete();
571        }
572    }
573
574    private void getCorporaToQuery(Consumer<List<Corpus>> consumer) {
575        // Always query all corpora, so that all corpus result counts are valid
576        getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer));
577    }
578
579    protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
580            final Suggestions suggestions) {
581        ShortcutRepository shortcutRepo = getShortcutRepository();
582        if (shortcutRepo == null) return;
583        if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) {
584            return;
585        }
586        Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
587                new Consumer<ShortcutCursor>() {
588            public boolean consume(ShortcutCursor shortcuts) {
589                suggestions.setShortcuts(shortcuts);
590                return true;
591            }
592        });
593        shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer);
594    }
595
596    public void updateSuggestions(String untrimmedQuery) {
597        final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery);
598        if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + getCorpus() + ")");
599        getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
600        getCorporaToQuery(new Consumer<List<Corpus>>(){
601            @Override
602            public boolean consume(List<Corpus> corporaToQuery) {
603                updateSuggestions(query, corporaToQuery);
604                return true;
605            }
606        });
607    }
608
609    protected void updateSuggestions(String query, List<Corpus> corporaToQuery) {
610        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
611                query, corporaToQuery);
612        getShortcutsForQuery(query, corporaToQuery, suggestions);
613
614        // Log start latency if this is the first suggestions update
615        gotSuggestions(suggestions);
616
617        showSuggestions(suggestions);
618    }
619
620    protected void showSuggestions(Suggestions suggestions) {
621        mSearchActivityView.setSuggestions(suggestions);
622    }
623
624    private class ClickHandler implements SuggestionClickListener {
625
626        public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) {
627            clickedQuickContact(adapter, position);
628        }
629
630        public void onSuggestionClicked(SuggestionsAdapter adapter, int position) {
631            launchSuggestion(adapter, position);
632        }
633
634        public boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
635            return SearchActivity.this.onSuggestionLongClicked(adapter, position);
636        }
637
638        public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) {
639            refineSuggestion(adapter, position);
640        }
641    }
642
643    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
644        public void onDismiss(DialogInterface dialog) {
645            if (DBG) Log.d(TAG, "Corpus selector dismissed");
646            clearStartedIntoCorpusSelectionDialog();
647        }
648    }
649
650    private class CorporaObserver extends DataSetObserver {
651        @Override
652        public void onChanged() {
653            setCorpus(getCorpusName());
654            updateSuggestions(getQuery());
655        }
656    }
657
658    public interface OnDestroyListener {
659        void onDestroyed();
660    }
661
662}
663