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 android.app.Activity;
20import android.app.SearchManager;
21import android.content.Intent;
22import android.net.Uri;
23import android.os.Bundle;
24import android.os.Debug;
25import android.os.Handler;
26import android.text.TextUtils;
27import android.util.Log;
28import android.view.Menu;
29import android.view.View;
30
31import com.android.common.Search;
32import com.android.quicksearchbox.ui.SearchActivityView;
33import com.android.quicksearchbox.ui.SuggestionClickListener;
34import com.android.quicksearchbox.ui.SuggestionsAdapter;
35import com.google.common.annotations.VisibleForTesting;
36import com.google.common.base.CharMatcher;
37
38import java.io.File;
39
40/**
41 * The main activity for Quick Search Box. Shows the search UI.
42 *
43 */
44public class SearchActivity extends Activity {
45
46    private static final boolean DBG = false;
47    private static final String TAG = "QSB.SearchActivity";
48
49    private static final String SCHEME_CORPUS = "qsb.corpus";
50
51    private static final String INTENT_EXTRA_TRACE_START_UP = "trace_start_up";
52
53    // Keys for the saved instance state.
54    private static final String INSTANCE_KEY_QUERY = "query";
55
56    private static final String ACTIVITY_HELP_CONTEXT = "search";
57
58    private boolean mTraceStartUp;
59    // Measures time from for last onCreate()/onNewIntent() call.
60    private LatencyTracker mStartLatencyTracker;
61    // Measures time spent inside onCreate()
62    private LatencyTracker mOnCreateTracker;
63    private int mOnCreateLatency;
64    // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
65    private boolean mStarting;
66    // True if the user has taken some action, e.g. launching a search, voice search,
67    // or suggestions, since QSB was last started.
68    private boolean mTookAction;
69
70    private SearchActivityView mSearchActivityView;
71
72    private Source mSource;
73
74    private Bundle mAppSearchData;
75
76    private final Handler mHandler = new Handler();
77    private final Runnable mUpdateSuggestionsTask = new Runnable() {
78        @Override
79        public void run() {
80            updateSuggestions();
81        }
82    };
83
84    private final Runnable mShowInputMethodTask = new Runnable() {
85        @Override
86        public void run() {
87            mSearchActivityView.showInputMethodForQuery();
88        }
89    };
90
91    private OnDestroyListener mDestroyListener;
92
93    /** Called when the activity is first created. */
94    @Override
95    public void onCreate(Bundle savedInstanceState) {
96        mTraceStartUp = getIntent().hasExtra(INTENT_EXTRA_TRACE_START_UP);
97        if (mTraceStartUp) {
98            String traceFile = new File(getDir("traces", 0), "qsb-start.trace").getAbsolutePath();
99            Log.i(TAG, "Writing start-up trace to " + traceFile);
100            Debug.startMethodTracing(traceFile);
101        }
102        recordStartTime();
103        if (DBG) Log.d(TAG, "onCreate()");
104        super.onCreate(savedInstanceState);
105
106        // This forces the HTTP request to check the users domain to be
107        // sent as early as possible.
108        QsbApplication.get(this).getSearchBaseUrlHelper();
109
110        mSource = QsbApplication.get(this).getGoogleSource();
111
112        mSearchActivityView = setupContentView();
113
114        if (getConfig().showScrollingResults()) {
115            mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedResults());
116        } else {
117            mSearchActivityView.limitResultsToViewHeight();
118        }
119
120        mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() {
121            @Override
122            public boolean onSearchClicked(int method) {
123                return SearchActivity.this.onSearchClicked(method);
124            }
125        });
126
127        mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() {
128            @Override
129            public void onQueryChanged() {
130                updateSuggestionsBuffered();
131            }
132        });
133
134        mSearchActivityView.setSuggestionClickListener(new ClickHandler());
135
136        mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() {
137            @Override
138            public void onClick(View view) {
139                onVoiceSearchClicked();
140            }
141        });
142
143        View.OnClickListener finishOnClick = new View.OnClickListener() {
144            @Override
145            public void onClick(View v) {
146                finish();
147            }
148        };
149        mSearchActivityView.setExitClickListener(finishOnClick);
150
151        // First get setup from intent
152        Intent intent = getIntent();
153        setupFromIntent(intent);
154        // Then restore any saved instance state
155        restoreInstanceState(savedInstanceState);
156
157        // Do this at the end, to avoid updating the list view when setSource()
158        // is called.
159        mSearchActivityView.start();
160
161        recordOnCreateDone();
162    }
163
164    protected SearchActivityView setupContentView() {
165        setContentView(R.layout.search_activity);
166        return (SearchActivityView) findViewById(R.id.search_activity_view);
167    }
168
169    protected SearchActivityView getSearchActivityView() {
170        return mSearchActivityView;
171    }
172
173    @Override
174    protected void onNewIntent(Intent intent) {
175        if (DBG) Log.d(TAG, "onNewIntent()");
176        recordStartTime();
177        setIntent(intent);
178        setupFromIntent(intent);
179    }
180
181    private void recordStartTime() {
182        mStartLatencyTracker = new LatencyTracker();
183        mOnCreateTracker = new LatencyTracker();
184        mStarting = true;
185        mTookAction = false;
186    }
187
188    private void recordOnCreateDone() {
189        mOnCreateLatency = mOnCreateTracker.getLatency();
190    }
191
192    protected void restoreInstanceState(Bundle savedInstanceState) {
193        if (savedInstanceState == null) return;
194        String query = savedInstanceState.getString(INSTANCE_KEY_QUERY);
195        setQuery(query, false);
196    }
197
198    @Override
199    protected void onSaveInstanceState(Bundle outState) {
200        super.onSaveInstanceState(outState);
201        // We don't save appSearchData, since we always get the value
202        // from the intent and the user can't change it.
203
204        outState.putString(INSTANCE_KEY_QUERY, getQuery());
205    }
206
207    private void setupFromIntent(Intent intent) {
208        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
209        String corpusName = getCorpusNameFromUri(intent.getData());
210        String query = intent.getStringExtra(SearchManager.QUERY);
211        Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
212        boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
213
214        setQuery(query, selectAll);
215        mAppSearchData = appSearchData;
216
217    }
218
219    private String getCorpusNameFromUri(Uri uri) {
220        if (uri == null) return null;
221        if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
222        return uri.getAuthority();
223    }
224
225    private QsbApplication getQsbApplication() {
226        return QsbApplication.get(this);
227    }
228
229    private Config getConfig() {
230        return getQsbApplication().getConfig();
231    }
232
233    protected SearchSettings getSettings() {
234        return getQsbApplication().getSettings();
235    }
236
237    private SuggestionsProvider getSuggestionsProvider() {
238        return getQsbApplication().getSuggestionsProvider();
239    }
240
241    private Logger getLogger() {
242        return getQsbApplication().getLogger();
243    }
244
245    @VisibleForTesting
246    public void setOnDestroyListener(OnDestroyListener l) {
247        mDestroyListener = l;
248    }
249
250    @Override
251    protected void onDestroy() {
252        if (DBG) Log.d(TAG, "onDestroy()");
253        mSearchActivityView.destroy();
254        super.onDestroy();
255        if (mDestroyListener != null) {
256            mDestroyListener.onDestroyed();
257        }
258    }
259
260    @Override
261    protected void onStop() {
262        if (DBG) Log.d(TAG, "onStop()");
263        if (!mTookAction) {
264            // TODO: This gets logged when starting other activities, e.g. by opening the search
265            // settings, or clicking a notification in the status bar.
266            // TODO we should log both sets of suggestions in 2-pane mode
267            getLogger().logExit(getCurrentSuggestions(), getQuery().length());
268        }
269        // Close all open suggestion cursors. The query will be redone in onResume()
270        // if we come back to this activity.
271        mSearchActivityView.clearSuggestions();
272        mSearchActivityView.onStop();
273        super.onStop();
274    }
275
276    @Override
277    protected void onPause() {
278        if (DBG) Log.d(TAG, "onPause()");
279        mSearchActivityView.onPause();
280        super.onPause();
281    }
282
283    @Override
284    protected void onRestart() {
285        if (DBG) Log.d(TAG, "onRestart()");
286        super.onRestart();
287    }
288
289    @Override
290    protected void onResume() {
291        if (DBG) Log.d(TAG, "onResume()");
292        super.onResume();
293        updateSuggestionsBuffered();
294        mSearchActivityView.onResume();
295        if (mTraceStartUp) Debug.stopMethodTracing();
296    }
297
298    @Override
299    public boolean onPrepareOptionsMenu(Menu menu) {
300        // Since the menu items are dynamic, we recreate the menu every time.
301        menu.clear();
302        createMenuItems(menu, true);
303        return true;
304    }
305
306    public void createMenuItems(Menu menu, boolean showDisabled) {
307        getQsbApplication().getHelp().addHelpMenuItem(menu, ACTIVITY_HELP_CONTEXT);
308    }
309
310    @Override
311    public void onWindowFocusChanged(boolean hasFocus) {
312        super.onWindowFocusChanged(hasFocus);
313        if (hasFocus) {
314            // Launch the IME after a bit
315            mHandler.postDelayed(mShowInputMethodTask, 0);
316        }
317    }
318
319    protected String getQuery() {
320        return mSearchActivityView.getQuery();
321    }
322
323    protected void setQuery(String query, boolean selectAll) {
324        mSearchActivityView.setQuery(query, selectAll);
325    }
326
327    /**
328     * @return true if a search was performed as a result of this click, false otherwise.
329     */
330    protected boolean onSearchClicked(int method) {
331        String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
332        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
333
334        // Don't do empty queries
335        if (TextUtils.getTrimmedLength(query) == 0) return false;
336
337        mTookAction = true;
338
339        // Log search start
340        getLogger().logSearch(method, query.length());
341
342        // Start search
343        startSearch(mSource, query);
344        return true;
345    }
346
347    protected void startSearch(Source searchSource, String query) {
348        Intent intent = searchSource.createSearchIntent(query, mAppSearchData);
349        launchIntent(intent);
350    }
351
352    protected void onVoiceSearchClicked() {
353        if (DBG) Log.d(TAG, "Voice Search clicked");
354
355        mTookAction = true;
356
357        // Log voice search start
358        getLogger().logVoiceSearch();
359
360        // Start voice search
361        Intent intent = mSource.createVoiceSearchIntent(mAppSearchData);
362        launchIntent(intent);
363    }
364
365    protected Source getSearchSource() {
366        return mSource;
367    }
368
369    protected SuggestionCursor getCurrentSuggestions() {
370        return mSearchActivityView.getSuggestions().getResult();
371    }
372
373    protected SuggestionPosition getCurrentSuggestions(SuggestionsAdapter<?> adapter, long id) {
374        SuggestionPosition pos = adapter.getSuggestion(id);
375        if (pos == null) {
376            return null;
377        }
378        SuggestionCursor suggestions = pos.getCursor();
379        int position = pos.getPosition();
380        if (suggestions == null) {
381            return null;
382        }
383        int count = suggestions.getCount();
384        if (position < 0 || position >= count) {
385            Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
386            return null;
387        }
388        suggestions.moveTo(position);
389        return pos;
390    }
391
392    protected void launchIntent(Intent intent) {
393        if (DBG) Log.d(TAG, "launchIntent " + intent);
394        if (intent == null) {
395            return;
396        }
397        try {
398            startActivity(intent);
399        } catch (RuntimeException ex) {
400            // Since the intents for suggestions specified by suggestion providers,
401            // guard against them not being handled, not allowed, etc.
402            Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
403        }
404    }
405
406    private boolean launchSuggestion(SuggestionsAdapter<?> adapter, long id) {
407        SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
408        if (suggestion == null) return false;
409
410        if (DBG) Log.d(TAG, "Launching suggestion " + id);
411        mTookAction = true;
412
413        // Log suggestion click
414        getLogger().logSuggestionClick(id, suggestion.getCursor(),
415                Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
416
417        // Launch intent
418        launchSuggestion(suggestion.getCursor(), suggestion.getPosition());
419
420        return true;
421    }
422
423    protected void launchSuggestion(SuggestionCursor suggestions, int position) {
424        suggestions.moveTo(position);
425        Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
426        launchIntent(intent);
427    }
428
429    protected void refineSuggestion(SuggestionsAdapter<?> adapter, long id) {
430        if (DBG) Log.d(TAG, "query refine clicked, pos " + id);
431        SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
432        if (suggestion == null) {
433            return;
434        }
435        String query = suggestion.getSuggestionQuery();
436        if (TextUtils.isEmpty(query)) {
437            return;
438        }
439
440        // Log refine click
441        getLogger().logSuggestionClick(id, suggestion.getCursor(),
442                Logger.SUGGESTION_CLICK_TYPE_REFINE);
443
444        // Put query + space in query text view
445        String queryWithSpace = query + ' ';
446        setQuery(queryWithSpace, false);
447        updateSuggestions();
448        mSearchActivityView.focusQueryTextView();
449    }
450
451    private void updateSuggestionsBuffered() {
452        if (DBG) Log.d(TAG, "updateSuggestionsBuffered()");
453        mHandler.removeCallbacks(mUpdateSuggestionsTask);
454        long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
455        mHandler.postDelayed(mUpdateSuggestionsTask, delay);
456    }
457
458    private void gotSuggestions(Suggestions suggestions) {
459        if (mStarting) {
460            mStarting = false;
461            String source = getIntent().getStringExtra(Search.SOURCE);
462            int latency = mStartLatencyTracker.getLatency();
463            getLogger().logStart(mOnCreateLatency, latency, source);
464            getQsbApplication().onStartupComplete();
465        }
466    }
467
468    public void updateSuggestions() {
469        if (DBG) Log.d(TAG, "updateSuggestions()");
470        final String query = CharMatcher.WHITESPACE.trimLeadingFrom(getQuery());
471        updateSuggestions(query, mSource);
472    }
473
474    protected void updateSuggestions(String query, Source source) {
475        if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + source + ")");
476        Suggestions suggestions = getSuggestionsProvider().getSuggestions(
477                query, source);
478
479        // Log start latency if this is the first suggestions update
480        gotSuggestions(suggestions);
481
482        showSuggestions(suggestions);
483    }
484
485    protected void showSuggestions(Suggestions suggestions) {
486        mSearchActivityView.setSuggestions(suggestions);
487    }
488
489    private class ClickHandler implements SuggestionClickListener {
490
491        @Override
492        public void onSuggestionClicked(SuggestionsAdapter<?> adapter, long id) {
493            launchSuggestion(adapter, id);
494        }
495
496        @Override
497        public void onSuggestionQueryRefineClicked(SuggestionsAdapter<?> adapter, long id) {
498            refineSuggestion(adapter, id);
499        }
500    }
501
502    public interface OnDestroyListener {
503        void onDestroyed();
504    }
505
506}
507