blob: a6a96e91f266aa3295d488a1ba36a71cd8325b73 [file] [log] [blame]
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.quicksearchbox;
import com.android.common.Search;
import com.android.quicksearchbox.ui.SearchActivityView;
import com.android.quicksearchbox.ui.SuggestionClickListener;
import com.android.quicksearchbox.ui.SuggestionsAdapter;
import com.android.quicksearchbox.util.Consumer;
import com.android.quicksearchbox.util.Consumers;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.SearchManager;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.DataSetObserver;
import android.net.Uri;
import android.os.Bundle;
import android.os.Debug;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;
import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/**
* The main activity for Quick Search Box. Shows the search UI.
*
*/
public class SearchActivity extends Activity {
private static final boolean DBG = false;
private static final String TAG = "QSB.SearchActivity";
private static final String SCHEME_CORPUS = "qsb.corpus";
public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS
= "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS";
private static final String INTENT_EXTRA_TRACE_START_UP = "trace_start_up";
// Keys for the saved instance state.
private static final String INSTANCE_KEY_CORPUS = "corpus";
private static final String INSTANCE_KEY_QUERY = "query";
private static final String ACTIVITY_HELP_CONTEXT = "search";
private boolean mTraceStartUp;
// Measures time from for last onCreate()/onNewIntent() call.
private LatencyTracker mStartLatencyTracker;
// Measures time spent inside onCreate()
private LatencyTracker mOnCreateTracker;
private int mOnCreateLatency;
// Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume().
private boolean mStarting;
// True if the user has taken some action, e.g. launching a search, voice search,
// or suggestions, since QSB was last started.
private boolean mTookAction;
private SearchActivityView mSearchActivityView;
private CorporaObserver mCorporaObserver;
private Bundle mAppSearchData;
private final Handler mHandler = new Handler();
private final Runnable mUpdateSuggestionsTask = new Runnable() {
public void run() {
updateSuggestions();
}
};
private final Runnable mShowInputMethodTask = new Runnable() {
public void run() {
mSearchActivityView.showInputMethodForQuery();
}
};
private OnDestroyListener mDestroyListener;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
mTraceStartUp = getIntent().hasExtra(INTENT_EXTRA_TRACE_START_UP);
if (mTraceStartUp) {
String traceFile = new File(getDir("traces", 0), "qsb-start.trace").getAbsolutePath();
Log.i(TAG, "Writing start-up trace to " + traceFile);
Debug.startMethodTracing(traceFile);
}
recordStartTime();
if (DBG) Log.d(TAG, "onCreate()");
super.onCreate(savedInstanceState);
// This forces the HTTP request to check the users domain to be
// sent as early as possible.
QsbApplication.get(this).getSearchBaseUrlHelper();
mSearchActivityView = setupContentView();
if (getConfig().showScrollingSuggestions()) {
mSearchActivityView.setMaxPromotedSuggestions(getConfig().getMaxPromotedSuggestions());
} else {
mSearchActivityView.limitSuggestionsToViewHeight();
}
if (getConfig().showScrollingResults()) {
mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedResults());
} else {
mSearchActivityView.limitResultsToViewHeight();
}
mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() {
public boolean onSearchClicked(int method) {
return SearchActivity.this.onSearchClicked(method);
}
});
mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() {
public void onQueryChanged() {
updateSuggestionsBuffered();
}
});
mSearchActivityView.setSuggestionClickListener(new ClickHandler());
mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() {
public void onClick(View view) {
onVoiceSearchClicked();
}
});
View.OnClickListener finishOnClick = new View.OnClickListener() {
public void onClick(View v) {
finish();
}
};
mSearchActivityView.setExitClickListener(finishOnClick);
// First get setup from intent
Intent intent = getIntent();
setupFromIntent(intent);
// Then restore any saved instance state
restoreInstanceState(savedInstanceState);
// Do this at the end, to avoid updating the list view when setSource()
// is called.
mSearchActivityView.start();
mCorporaObserver = new CorporaObserver();
getCorpora().registerDataSetObserver(mCorporaObserver);
recordOnCreateDone();
}
protected SearchActivityView setupContentView() {
setContentView(R.layout.search_activity);
return (SearchActivityView) findViewById(R.id.search_activity_view);
}
protected SearchActivityView getSearchActivityView() {
return mSearchActivityView;
}
@Override
protected void onNewIntent(Intent intent) {
if (DBG) Log.d(TAG, "onNewIntent()");
recordStartTime();
setIntent(intent);
setupFromIntent(intent);
}
private void recordStartTime() {
mStartLatencyTracker = new LatencyTracker();
mOnCreateTracker = new LatencyTracker();
mStarting = true;
mTookAction = false;
}
private void recordOnCreateDone() {
mOnCreateLatency = mOnCreateTracker.getLatency();
}
protected void restoreInstanceState(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS);
String query = savedInstanceState.getString(INSTANCE_KEY_QUERY);
setCorpus(corpusName);
setQuery(query, false);
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// We don't save appSearchData, since we always get the value
// from the intent and the user can't change it.
outState.putString(INSTANCE_KEY_CORPUS, getCorpusName());
outState.putString(INSTANCE_KEY_QUERY, getQuery());
}
private void setupFromIntent(Intent intent) {
if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
String corpusName = getCorpusNameFromUri(intent.getData());
String query = intent.getStringExtra(SearchManager.QUERY);
Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
setCorpus(corpusName);
setQuery(query, selectAll);
mAppSearchData = appSearchData;
if (startedIntoCorpusSelectionDialog()) {
mSearchActivityView.showCorpusSelectionDialog();
}
}
public boolean startedIntoCorpusSelectionDialog() {
return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction());
}
/**
* Removes corpus selector intent action, so that BACK works normally after
* dismissing and reopening the corpus selector.
*/
public void clearStartedIntoCorpusSelectionDialog() {
Intent oldIntent = getIntent();
if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) {
Intent newIntent = new Intent(oldIntent);
newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH);
setIntent(newIntent);
}
}
public static Uri getCorpusUri(Corpus corpus) {
if (corpus == null) return null;
return new Uri.Builder()
.scheme(SCHEME_CORPUS)
.authority(corpus.getName())
.build();
}
private String getCorpusNameFromUri(Uri uri) {
if (uri == null) return null;
if (!SCHEME_CORPUS.equals(uri.getScheme())) return null;
return uri.getAuthority();
}
private Corpus getCorpus() {
return mSearchActivityView.getCorpus();
}
private String getCorpusName() {
return mSearchActivityView.getCorpusName();
}
private void setCorpus(String name) {
mSearchActivityView.setCorpus(name);
}
private QsbApplication getQsbApplication() {
return QsbApplication.get(this);
}
private Config getConfig() {
return getQsbApplication().getConfig();
}
protected SearchSettings getSettings() {
return getQsbApplication().getSettings();
}
private Corpora getCorpora() {
return getQsbApplication().getCorpora();
}
private CorpusRanker getCorpusRanker() {
return getQsbApplication().getCorpusRanker();
}
private ShortcutRepository getShortcutRepository() {
return getQsbApplication().getShortcutRepository();
}
private SuggestionsProvider getSuggestionsProvider() {
return getQsbApplication().getSuggestionsProvider();
}
private Logger getLogger() {
return getQsbApplication().getLogger();
}
@VisibleForTesting
public void setOnDestroyListener(OnDestroyListener l) {
mDestroyListener = l;
}
@Override
protected void onDestroy() {
if (DBG) Log.d(TAG, "onDestroy()");
getCorpora().unregisterDataSetObserver(mCorporaObserver);
mSearchActivityView.destroy();
super.onDestroy();
if (mDestroyListener != null) {
mDestroyListener.onDestroyed();
}
}
@Override
protected void onStop() {
if (DBG) Log.d(TAG, "onStop()");
if (!mTookAction) {
// TODO: This gets logged when starting other activities, e.g. by opening the search
// settings, or clicking a notification in the status bar.
// TODO we should log both sets of suggestions in 2-pane mode
getLogger().logExit(getCurrentSuggestions(), getQuery().length());
}
// Close all open suggestion cursors. The query will be redone in onResume()
// if we come back to this activity.
mSearchActivityView.clearSuggestions();
getQsbApplication().getShortcutRefresher().reset();
mSearchActivityView.onStop();
super.onStop();
}
@Override
protected void onPause() {
if (DBG) Log.d(TAG, "onPause()");
mSearchActivityView.onPause();
super.onPause();
}
@Override
protected void onRestart() {
if (DBG) Log.d(TAG, "onRestart()");
super.onRestart();
}
@Override
protected void onResume() {
if (DBG) Log.d(TAG, "onResume()");
super.onResume();
updateSuggestionsBuffered();
mSearchActivityView.onResume();
if (mTraceStartUp) Debug.stopMethodTracing();
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
// Since the menu items are dynamic, we recreate the menu every time.
menu.clear();
createMenuItems(menu, true);
return true;
}
public void createMenuItems(Menu menu, boolean showDisabled) {
getSettings().addMenuItems(menu, showDisabled);
getQsbApplication().getHelp().addHelpMenuItem(menu, ACTIVITY_HELP_CONTEXT);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
// Launch the IME after a bit
mHandler.postDelayed(mShowInputMethodTask, 0);
}
}
protected String getQuery() {
return mSearchActivityView.getQuery();
}
protected void setQuery(String query, boolean selectAll) {
mSearchActivityView.setQuery(query, selectAll);
}
public CorpusSelectionDialog getCorpusSelectionDialog() {
CorpusSelectionDialog dialog = createCorpusSelectionDialog();
dialog.setOwnerActivity(this);
dialog.setOnDismissListener(new CorpusSelectorDismissListener());
return dialog;
}
protected CorpusSelectionDialog createCorpusSelectionDialog() {
return new CorpusSelectionDialog(this, getSettings());
}
/**
* @return true if a search was performed as a result of this click, false otherwise.
*/
protected boolean onSearchClicked(int method) {
String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' ');
if (DBG) Log.d(TAG, "Search clicked, query=" + query);
// Don't do empty queries
if (TextUtils.getTrimmedLength(query) == 0) return false;
Corpus searchCorpus = getSearchCorpus();
if (searchCorpus == null) return false;
mTookAction = true;
// Log search start
getLogger().logSearch(getCorpus(), method, query.length());
// Start search
startSearch(searchCorpus, query);
return true;
}
protected void startSearch(Corpus searchCorpus, String query) {
Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData);
launchIntent(intent);
}
protected void onVoiceSearchClicked() {
if (DBG) Log.d(TAG, "Voice Search clicked");
Corpus searchCorpus = getSearchCorpus();
if (searchCorpus == null) return;
mTookAction = true;
// Log voice search start
getLogger().logVoiceSearch(searchCorpus);
// Start voice search
Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData);
launchIntent(intent);
}
protected Corpus getSearchCorpus() {
return mSearchActivityView.getSearchCorpus();
}
protected SuggestionCursor getCurrentSuggestions() {
return mSearchActivityView.getCurrentPromotedSuggestions();
}
protected SuggestionPosition getCurrentSuggestions(SuggestionsAdapter<?> adapter, long id) {
SuggestionPosition pos = adapter.getSuggestion(id);
if (pos == null) {
return null;
}
SuggestionCursor suggestions = pos.getCursor();
int position = pos.getPosition();
if (suggestions == null) {
return null;
}
int count = suggestions.getCount();
if (position < 0 || position >= count) {
Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count);
return null;
}
suggestions.moveTo(position);
return pos;
}
protected Set<Corpus> getCurrentIncludedCorpora() {
Suggestions suggestions = mSearchActivityView.getSuggestions();
return suggestions == null ? null : suggestions.getIncludedCorpora();
}
protected void launchIntent(Intent intent) {
if (DBG) Log.d(TAG, "launchIntent " + intent);
if (intent == null) {
return;
}
try {
startActivity(intent);
} catch (RuntimeException ex) {
// Since the intents for suggestions specified by suggestion providers,
// guard against them not being handled, not allowed, etc.
Log.e(TAG, "Failed to start " + intent.toUri(0), ex);
}
}
private boolean launchSuggestion(SuggestionsAdapter<?> adapter, long id) {
SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
if (suggestion == null) return false;
if (DBG) Log.d(TAG, "Launching suggestion " + id);
mTookAction = true;
// Log suggestion click
getLogger().logSuggestionClick(id, suggestion.getCursor(), getCurrentIncludedCorpora(),
Logger.SUGGESTION_CLICK_TYPE_LAUNCH);
// Create shortcut
getShortcutRepository().reportClick(suggestion.getCursor(), suggestion.getPosition());
// Launch intent
launchSuggestion(suggestion.getCursor(), suggestion.getPosition());
return true;
}
protected void launchSuggestion(SuggestionCursor suggestions, int position) {
suggestions.moveTo(position);
Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData);
launchIntent(intent);
}
protected void removeFromHistoryClicked(final SuggestionsAdapter<?> adapter,
final long id) {
SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
if (suggestion == null) return;
CharSequence title = suggestion.getSuggestionText1();
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle(title)
.setMessage(R.string.remove_from_history)
.setPositiveButton(android.R.string.ok,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
// TODO: what if the suggestions have changed?
removeFromHistory(adapter, id);
}
})
.setNegativeButton(android.R.string.cancel, null)
.create();
dialog.show();
}
protected void removeFromHistory(SuggestionsAdapter<?> adapter, long id) {
SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
if (suggestion == null) return;
removeFromHistory(suggestion.getCursor(), suggestion.getPosition());
// TODO: Log to event log?
}
protected void removeFromHistory(SuggestionCursor suggestions, int position) {
removeShortcut(suggestions, position);
removeFromHistoryDone(true);
}
protected void removeFromHistoryDone(boolean ok) {
Log.i(TAG, "Removed query from history, success=" + ok);
updateSuggestionsBuffered();
if (!ok) {
Toast.makeText(this, R.string.remove_from_history_failed, Toast.LENGTH_SHORT).show();
}
}
protected void removeShortcut(SuggestionCursor suggestions, int position) {
if (suggestions.isSuggestionShortcut()) {
if (DBG) Log.d(TAG, "Removing suggestion " + position + " from shortcuts");
getShortcutRepository().removeFromHistory(suggestions, position);
}
}
protected void clickedQuickContact(SuggestionsAdapter<?> adapter, long id) {
SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
if (suggestion == null) return;
if (DBG) Log.d(TAG, "Used suggestion " + suggestion.getPosition());
mTookAction = true;
// Log suggestion click
getLogger().logSuggestionClick(id, suggestion.getCursor(), getCurrentIncludedCorpora(),
Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT);
// Create shortcut
getShortcutRepository().reportClick(suggestion.getCursor(), suggestion.getPosition());
}
protected void refineSuggestion(SuggestionsAdapter<?> adapter, long id) {
if (DBG) Log.d(TAG, "query refine clicked, pos " + id);
SuggestionPosition suggestion = getCurrentSuggestions(adapter, id);
if (suggestion == null) {
return;
}
String query = suggestion.getSuggestionQuery();
if (TextUtils.isEmpty(query)) {
return;
}
// Log refine click
getLogger().logSuggestionClick(id, suggestion.getCursor(), getCurrentIncludedCorpora(),
Logger.SUGGESTION_CLICK_TYPE_REFINE);
// Put query + space in query text view
String queryWithSpace = query + ' ';
setQuery(queryWithSpace, false);
updateSuggestions();
mSearchActivityView.focusQueryTextView();
}
private void updateSuggestionsBuffered() {
if (DBG) Log.d(TAG, "updateSuggestionsBuffered()");
mHandler.removeCallbacks(mUpdateSuggestionsTask);
long delay = getConfig().getTypingUpdateSuggestionsDelayMillis();
mHandler.postDelayed(mUpdateSuggestionsTask, delay);
}
private void gotSuggestions(Suggestions suggestions) {
if (mStarting) {
mStarting = false;
String source = getIntent().getStringExtra(Search.SOURCE);
int latency = mStartLatencyTracker.getLatency();
getLogger().logStart(mOnCreateLatency, latency, source, getCorpus(),
suggestions == null ? null : suggestions.getExpectedCorpora());
getQsbApplication().onStartupComplete();
}
}
private void getCorporaToQuery(Consumer<List<Corpus>> consumer) {
Corpus corpus = getCorpus();
if (corpus == null) {
getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer));
} else {
List<Corpus> corpora = new ArrayList<Corpus>();
Corpus searchCorpus = getSearchCorpus();
if (searchCorpus != null) corpora.add(searchCorpus);
consumer.consume(corpora);
}
}
protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery,
final Suggestions suggestions) {
ShortcutRepository shortcutRepo = getShortcutRepository();
if (shortcutRepo == null) return;
if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) {
return;
}
Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler,
new Consumer<ShortcutCursor>() {
public boolean consume(ShortcutCursor shortcuts) {
suggestions.setShortcuts(shortcuts);
return true;
}
});
shortcutRepo.getShortcutsForQuery(query, corporaToQuery,
getSettings().allowWebSearchShortcuts(), consumer);
}
public void updateSuggestions() {
if (DBG) Log.d(TAG, "updateSuggestions()");
final String query = CharMatcher.WHITESPACE.trimLeadingFrom(getQuery());
getQsbApplication().getSourceTaskExecutor().cancelPendingTasks();
getCorporaToQuery(new Consumer<List<Corpus>>(){
@Override
public boolean consume(List<Corpus> corporaToQuery) {
updateSuggestions(query, corporaToQuery);
return true;
}
});
}
protected void updateSuggestions(String query, List<Corpus> corporaToQuery) {
if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + corporaToQuery + ")");
Suggestions suggestions = getSuggestionsProvider().getSuggestions(
query, corporaToQuery);
getShortcutsForQuery(query, corporaToQuery, suggestions);
// Log start latency if this is the first suggestions update
gotSuggestions(suggestions);
showSuggestions(suggestions);
}
protected void showSuggestions(Suggestions suggestions) {
mSearchActivityView.setSuggestions(suggestions);
}
private class ClickHandler implements SuggestionClickListener {
public void onSuggestionQuickContactClicked(SuggestionsAdapter<?> adapter, long id) {
clickedQuickContact(adapter, id);
}
public void onSuggestionClicked(SuggestionsAdapter<?> adapter, long id) {
launchSuggestion(adapter, id);
}
public void onSuggestionRemoveFromHistoryClicked(SuggestionsAdapter<?> adapter, long id) {
removeFromHistoryClicked(adapter, id);
}
public void onSuggestionQueryRefineClicked(SuggestionsAdapter<?> adapter, long id) {
refineSuggestion(adapter, id);
}
}
private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener {
public void onDismiss(DialogInterface dialog) {
if (DBG) Log.d(TAG, "Corpus selector dismissed");
clearStartedIntoCorpusSelectionDialog();
}
}
private class CorporaObserver extends DataSetObserver {
@Override
public void onChanged() {
setCorpus(getCorpusName());
updateSuggestions();
}
}
public interface OnDestroyListener {
void onDestroyed();
}
}