SearchActivity.java revision f5a8912d5da80378d38b667eba4aaa0555aea7bd
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        } catch (RuntimeException ex) {
480            // Since the intents for suggestions specified by suggestion providers,
481            // guard against them not being handled, not allowed, etc.
482            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
483        }
484    }
485
486    private boolean launchSuggestion(SuggestionsAdapter adapter, int position) {
487        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
488        if (suggestions == null) return false;
489
490        if (DBG) Log.d(TAG, "Launching suggestion " + position);
491        mTookAction = true;
492
493        // Log suggestion click
494        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
495                Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
496
497        // Create shortcut
498        getShortcutRepository().reportClick(suggestions, position);
499
500        // Launch intent
501        launchSuggestion(suggestions, position);
502
503        return true;
504    }
505
506    protected void launchSuggestion(SuggestionCursor suggestions, int position) {
507        suggestions.moveTo(position);
508        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
509        launchIntent(intent);
510    }
511
512    protected void clickedQuickContact(SuggestionsAdapter adapter, int position) {
513        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
514        if (suggestions == null) return;
515
516        if (DBG) Log.d(TAG, "Used suggestion " + position);
517        mTookAction = true;
518
519        // Log suggestion click
520        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
521                Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
522
523        // Create shortcut
524        getShortcutRepository().reportClick(suggestions, position);
525    }
526
527    protected void refineSuggestion(SuggestionsAdapter adapter, int position) {
528        if (DBG) Log.d(TAG, "query refine clicked, pos " + position);
529        SuggestionCursor suggestions = getCurrentSuggestions(adapter, position);
530        if (suggestions == null) {
531            return;
532        }
533        String query = suggestions.getSuggestionQuery();
534        if (TextUtils.isEmpty(query)) {
535            return;
536        }
537
538        // Log refine click
539        getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(),
540                Logger.SUGGESTION_CLICK_TYPE_REFINE);
541
542        // Put query + space in query text view
543        String queryWithSpace = query + ' ';
544        setQuery(queryWithSpace, false);
545        updateSuggestions(queryWithSpace);
546        mSearchActivityView.focusQueryTextView();
547    }
548
549    protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
550        if (DBG) Log.d(TAG, "Long clicked on suggestion " + position);
551        return false;
552    }
553
554    private void updateSuggestionsBuffered() {
555        mHandler.removeCallbacks(mUpdateSuggestionsTask);
556        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
557        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
558    }
559
560    private void gotSuggestions(Suggestions suggestions) {
561        if (mStarting) {
562            mStarting = false;
563            String source = getIntent().getStringExtra(Search.SOURCE);
564            int latency = mStartLatencyTracker.getLatency();
565            getLogger().logStart(latency, source, getCorpus(),
566                    suggestions == null ? null : suggestions.getExpectedCorpora());
567            getQsbApplication().onStartupComplete();
568        }
569    }
570
571    private void getCorporaToQuery(Consumer<List<Corpus>> consumer) {
572        // Always query all corpora, so that all corpus result counts are valid
573        getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer));
574    }
575
576    protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
577            final Suggestions suggestions) {
578        ShortcutRepository shortcutRepo = getShortcutRepository();
579        if (shortcutRepo == null) return;
580        if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) {
581            return;
582        }
583        Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
584                new Consumer<ShortcutCursor>() {
585            public boolean consume(ShortcutCursor shortcuts) {
586                suggestions.setShortcuts(shortcuts);
587                return true;
588            }
589        });
590        shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer);
591    }
592
593    public void updateSuggestions(String untrimmedQuery) {
594        final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery);
595        if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + getCorpus() + ")");
596        getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
597        getCorporaToQuery(new Consumer<List<Corpus>>(){
598            @Override
599            public boolean consume(List<Corpus> corporaToQuery) {
600                updateSuggestions(query, corporaToQuery);
601                return true;
602            }
603        });
604    }
605
606    protected void updateSuggestions(String query, List<Corpus> corporaToQuery) {
607        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
608                query, corporaToQuery);
609        getShortcutsForQuery(query, corporaToQuery, suggestions);
610
611        // Log start latency if this is the first suggestions update
612        gotSuggestions(suggestions);
613
614        showSuggestions(suggestions);
615    }
616
617    protected void showSuggestions(Suggestions suggestions) {
618        mSearchActivityView.setSuggestions(suggestions);
619    }
620
621    private class ClickHandler implements SuggestionClickListener {
622
623        public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) {
624            clickedQuickContact(adapter, position);
625        }
626
627        public void onSuggestionClicked(SuggestionsAdapter adapter, int position) {
628            launchSuggestion(adapter, position);
629        }
630
631        public boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) {
632            return SearchActivity.this.onSuggestionLongClicked(adapter, position);
633        }
634
635        public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) {
636            refineSuggestion(adapter, position);
637        }
638    }
639
640    private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
641        public void onDismiss(DialogInterface dialog) {
642            if (DBG) Log.d(TAG, "Corpus selector dismissed");
643            clearStartedIntoCorpusSelectionDialog();
644        }
645    }
646
647    private class CorporaObserver extends DataSetObserver {
648        @Override
649        public void onChanged() {
650            setCorpus(getCorpusName());
651            updateSuggestions(getQuery());
652        }
653    }
654
655    public interface OnDestroyListener {
656        void onDestroyed();
657    }
658
659}
660