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