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 
17 package com.android.quicksearchbox;
18 
19 import android.app.Activity;
20 import android.app.SearchManager;
21 import android.content.Intent;
22 import android.net.Uri;
23 import android.os.Bundle;
24 import android.os.Debug;
25 import android.os.Handler;
26 import android.text.TextUtils;
27 import android.util.Log;
28 import android.view.Menu;
29 import android.view.View;
30 
31 import com.android.common.Search;
32 import com.android.quicksearchbox.ui.SearchActivityView;
33 import com.android.quicksearchbox.ui.SuggestionClickListener;
34 import com.android.quicksearchbox.ui.SuggestionsAdapter;
35 import com.google.common.annotations.VisibleForTesting;
36 import com.google.common.base.CharMatcher;
37 
38 import java.io.File;
39 
40 /**
41  * The main activity for Quick Search Box. Shows the search UI.
42  *
43  */
44 public 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
onCreate(Bundle savedInstanceState)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 
setupContentView()164     protected SearchActivityView setupContentView() {
165         setContentView(R.layout.search_activity);
166         return (SearchActivityView) findViewById(R.id.search_activity_view);
167     }
168 
getSearchActivityView()169     protected SearchActivityView getSearchActivityView() {
170         return mSearchActivityView;
171     }
172 
173     @Override
onNewIntent(Intent intent)174     protected void onNewIntent(Intent intent) {
175         if (DBG) Log.d(TAG, "onNewIntent()");
176         recordStartTime();
177         setIntent(intent);
178         setupFromIntent(intent);
179     }
180 
recordStartTime()181     private void recordStartTime() {
182         mStartLatencyTracker = new LatencyTracker();
183         mOnCreateTracker = new LatencyTracker();
184         mStarting = true;
185         mTookAction = false;
186     }
187 
recordOnCreateDone()188     private void recordOnCreateDone() {
189         mOnCreateLatency = mOnCreateTracker.getLatency();
190     }
191 
restoreInstanceState(Bundle savedInstanceState)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
onSaveInstanceState(Bundle outState)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 
setupFromIntent(Intent intent)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 
getCorpusNameFromUri(Uri uri)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 
getQsbApplication()225     private QsbApplication getQsbApplication() {
226         return QsbApplication.get(this);
227     }
228 
getConfig()229     private Config getConfig() {
230         return getQsbApplication().getConfig();
231     }
232 
getSettings()233     protected SearchSettings getSettings() {
234         return getQsbApplication().getSettings();
235     }
236 
getSuggestionsProvider()237     private SuggestionsProvider getSuggestionsProvider() {
238         return getQsbApplication().getSuggestionsProvider();
239     }
240 
getLogger()241     private Logger getLogger() {
242         return getQsbApplication().getLogger();
243     }
244 
245     @VisibleForTesting
setOnDestroyListener(OnDestroyListener l)246     public void setOnDestroyListener(OnDestroyListener l) {
247         mDestroyListener = l;
248     }
249 
250     @Override
onDestroy()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
onStop()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
onPause()277     protected void onPause() {
278         if (DBG) Log.d(TAG, "onPause()");
279         mSearchActivityView.onPause();
280         super.onPause();
281     }
282 
283     @Override
onRestart()284     protected void onRestart() {
285         if (DBG) Log.d(TAG, "onRestart()");
286         super.onRestart();
287     }
288 
289     @Override
onResume()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
onPrepareOptionsMenu(Menu menu)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 
createMenuItems(Menu menu, boolean showDisabled)306     public void createMenuItems(Menu menu, boolean showDisabled) {
307         getQsbApplication().getHelp().addHelpMenuItem(menu, ACTIVITY_HELP_CONTEXT);
308     }
309 
310     @Override
onWindowFocusChanged(boolean hasFocus)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 
getQuery()319     protected String getQuery() {
320         return mSearchActivityView.getQuery();
321     }
322 
setQuery(String query, boolean selectAll)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      */
onSearchClicked(int method)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 
startSearch(Source searchSource, String query)347     protected void startSearch(Source searchSource, String query) {
348         Intent intent = searchSource.createSearchIntent(query, mAppSearchData);
349         launchIntent(intent);
350     }
351 
onVoiceSearchClicked()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 
getSearchSource()365     protected Source getSearchSource() {
366         return mSource;
367     }
368 
getCurrentSuggestions()369     protected SuggestionCursor getCurrentSuggestions() {
370         return mSearchActivityView.getSuggestions().getResult();
371     }
372 
getCurrentSuggestions(SuggestionsAdapter<?> adapter, long id)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 
launchIntent(Intent intent)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 
launchSuggestion(SuggestionsAdapter<?> adapter, long id)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 
launchSuggestion(SuggestionCursor suggestions, int position)423     protected void launchSuggestion(SuggestionCursor suggestions, int position) {
424         suggestions.moveTo(position);
425         Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
426         launchIntent(intent);
427     }
428 
refineSuggestion(SuggestionsAdapter<?> adapter, long id)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 
updateSuggestionsBuffered()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 
gotSuggestions(Suggestions suggestions)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 
updateSuggestions()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 
updateSuggestions(String query, Source source)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 
showSuggestions(Suggestions suggestions)485     protected void showSuggestions(Suggestions suggestions) {
486         mSearchActivityView.setSuggestions(suggestions);
487     }
488 
489     private class ClickHandler implements SuggestionClickListener {
490 
491         @Override
onSuggestionClicked(SuggestionsAdapter<?> adapter, long id)492         public void onSuggestionClicked(SuggestionsAdapter<?> adapter, long id) {
493             launchSuggestion(adapter, id);
494         }
495 
496         @Override
onSuggestionQueryRefineClicked(SuggestionsAdapter<?> adapter, long id)497         public void onSuggestionQueryRefineClicked(SuggestionsAdapter<?> adapter, long id) {
498             refineSuggestion(adapter, id);
499         }
500     }
501 
502     public interface OnDestroyListener {
onDestroyed()503         void onDestroyed();
504     }
505 
506 }
507