blob: 315b659db384f7c20a469370280490e544d471f5 [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.SearchSourceSelector;
import com.android.quicksearchbox.ui.SuggestionClickListener;
import com.android.quicksearchbox.ui.SuggestionSelectionListener;
import com.android.quicksearchbox.ui.SuggestionViewFactory;
import com.android.quicksearchbox.ui.SuggestionsAdapter;
import com.android.quicksearchbox.ui.SuggestionsView;
import android.app.Activity;
import android.app.Dialog;
import android.app.SearchManager;
import android.content.ComponentName;
import android.content.Intent;
import android.database.DataSetObserver;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.SystemClock;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.View;
import android.view.View.OnFocusChangeListener;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ImageButton;
import java.util.ArrayList;
/**
* The main activity for Quick Search Box. Shows the search UI.
*
*/
public class SearchActivity extends Activity {
private static final boolean DBG = true;
private static final String TAG = "QSB.SearchActivity";
public static final String INTENT_ACTION_QSB_AND_SELECT_SEARCH_SOURCE
= "com.android.quicksearchbox.action.QSB_AND_SELECT_SEARCH_SOURCE";
// Keys for the saved instance state.
private static final String INSTANCE_KEY_SOURCE = "source";
private static final String INSTANCE_KEY_USER_QUERY = "query";
// Dialog IDs
private static final int DIALOG_SOURCE_SELECTOR = 0;
// Timestamp for last onCreate()/onNewIntent() call, as returned by SystemClock.uptimeMillis().
private long mStartTime;
// 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;
protected SuggestionsAdapter mSuggestionsAdapter;
protected EditText mQueryTextView;
protected SuggestionsView mSuggestionsView;
protected ImageButton mSearchGoButton;
protected ImageButton mVoiceSearchButton;
protected SearchSourceSelector mSourceSelector;
private Launcher mLauncher;
private Source mSource;
private Bundle mAppSearchData;
private boolean mUpdateSuggestions;
private String mUserQuery;
private boolean mSelectAll;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
recordStartTime();
if (DBG) Log.d(TAG, "onCreate()");
super.onCreate(savedInstanceState);
setContentView(R.layout.search_bar);
mSuggestionsAdapter = getQsbApplication().createSuggestionsAdapter();
mQueryTextView = (EditText) findViewById(R.id.search_src_text);
mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
mSuggestionsView.setSuggestionClickListener(new ClickHandler());
mSuggestionsView.setSuggestionSelectionListener(new SelectionHandler());
mSuggestionsView.setInteractionListener(new InputMethodCloser());
mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
mSourceSelector = new SearchSourceSelector(findViewById(R.id.search_source_selector));
mLauncher = new Launcher(this);
// TODO: should this check for voice search in the current source?
mVoiceSearchButton.setVisibility(
mLauncher.isVoiceSearchAvailable() ? View.VISIBLE : View.GONE);
mQueryTextView.addTextChangedListener(new SearchTextWatcher());
mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
mSourceSelector.setOnClickListener(new SourceSelectorClickListener());
mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener());
ButtonsKeyListener buttonsKeyListener = new ButtonsKeyListener();
mSearchGoButton.setOnKeyListener(buttonsKeyListener);
mVoiceSearchButton.setOnKeyListener(buttonsKeyListener);
mSourceSelector.setOnKeyListener(buttonsKeyListener);
mUpdateSuggestions = true;
// 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.
mSuggestionsView.setAdapter(mSuggestionsAdapter);
}
@Override
protected void onNewIntent(Intent intent) {
recordStartTime();
setIntent(intent);
setupFromIntent(intent);
}
private void recordStartTime() {
mStartTime = SystemClock.uptimeMillis();
mStarting = true;
mTookAction = false;
}
protected void restoreInstanceState(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
ComponentName sourceName = savedInstanceState.getParcelable(INSTANCE_KEY_SOURCE);
String query = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY);
setSource(getSourceByComponentName(sourceName));
setUserQuery(query);
}
@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.putParcelable(INSTANCE_KEY_SOURCE, getSourceName());
outState.putString(INSTANCE_KEY_USER_QUERY, mUserQuery);
}
private void setupFromIntent(Intent intent) {
if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
ComponentName sourceName = SearchSourceSelector.getSource(intent);
String query = intent.getStringExtra(SearchManager.QUERY);
Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
Source source = getSourceByComponentName(sourceName);
setSource(source);
setUserQuery(query);
mSelectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
setAppSearchData(appSearchData);
if (INTENT_ACTION_QSB_AND_SELECT_SEARCH_SOURCE.equals(intent.getAction())) {
showSourceSelectorDialog();
}
}
private Source getSourceByComponentName(ComponentName sourceName) {
if (sourceName == null) return null;
Source source = getSources().getSourceByComponentName(sourceName);
if (source == null) {
Log.w(TAG, "Unknown source " + sourceName);
return null;
}
return source;
}
private void setSource(Source source) {
if (DBG) Log.d(TAG, "setSource(" + source + ")");
mSource = source;
Drawable sourceIcon;
if (source == null) {
sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon();
} else {
sourceIcon = source.getSourceIcon();
}
ComponentName sourceName = getSourceName();
mSuggestionsAdapter.setSource(sourceName);
mSourceSelector.setSourceIcon(sourceIcon);
}
private ComponentName getSourceName() {
return mSource == null ? null : mSource.getComponentName();
}
private QsbApplication getQsbApplication() {
return (QsbApplication) getApplication();
}
private SourceLookup getSources() {
return getQsbApplication().getSources();
}
private ShortcutRepository getShortcutRepository() {
return getQsbApplication().getShortcutRepository();
}
private SuggestionsProvider getSuggestionsProvider() {
return getQsbApplication().getSuggestionsProvider(mSource);
}
private SuggestionViewFactory getSuggestionViewFactory() {
return getQsbApplication().getSuggestionViewFactory();
}
private Logger getLogger() {
return getQsbApplication().getLogger();
}
@Override
protected void onDestroy() {
if (DBG) Log.d(TAG, "onDestroy()");
super.onDestroy();
mSuggestionsView.setAdapter(null); // closes mSuggestionsAdapter
}
@Override
protected void onStop() {
if (DBG) Log.d(TAG, "onStop()");
// Close all open suggestion cursors. The query will be redone in onResume()
// if we come back to this activity.
mSuggestionsAdapter.setSuggestions(null);
getQsbApplication().getShortcutRefresher().reset();
super.onStop();
}
@Override
protected void onResume() {
if (DBG) Log.d(TAG, "onResume()");
super.onResume();
setQuery(mUserQuery, mSelectAll);
// Only select everything the first time after creating the activity.
mSelectAll = false;
updateSuggestions(mUserQuery);
mQueryTextView.requestFocus();
if (mStarting) {
mStarting = false;
// Start up latency should not exceed 2^31 ms (~ 25 days). Note that
// SystemClock.uptimeMillis() does not advance during deep sleep.
int latency = (int) (SystemClock.uptimeMillis() - mStartTime);
String source = getIntent().getStringExtra(Search.SOURCE);
getLogger().logStart(latency, source, mSource,
getSuggestionsProvider().getOrderedSources());
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
Intent settings = new Intent(SearchManager.INTENT_ACTION_SEARCH_SETTINGS);
settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
// Don't show activity chooser if there are multiple search settings activities,
// e.g. from different QSB implementations.
settings.setPackage(this.getPackageName());
menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings)
.setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P')
.setIntent(settings);
return true;
}
/**
* Sets the query as typed by the user. Does not update the suggestions
* or the text in the query box.
*/
protected void setUserQuery(String userQuery) {
if (userQuery == null) userQuery = "";
mUserQuery = userQuery;
}
protected void setAppSearchData(Bundle appSearchData) {
mAppSearchData = appSearchData;
mLauncher.setAppSearchData(appSearchData);
}
protected String getQuery() {
CharSequence q = mQueryTextView.getText();
return q == null ? "" : q.toString();
}
/**
* Restores the query entered by the user.
*/
private void restoreUserQuery() {
if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'");
setQuery(mUserQuery, false);
}
/**
* Sets the text in the query box. Does not update the suggestions,
* and does not change the saved user-entered query.
* {@link #restoreUserQuery()} will restore the query to the last
* user-entered query.
*/
private void setQuery(String query, boolean selectAll) {
mUpdateSuggestions = false;
mQueryTextView.setText(query);
setTextSelection(selectAll);
mUpdateSuggestions = true;
}
/**
* Sets the text selection in the query text view.
*
* @param selectAll If {@code true}, selects the entire query.
* If {@false}, no characters are selected, and the cursor is placed
* at the end of the query.
*/
private void setTextSelection(boolean selectAll) {
if (selectAll) {
mQueryTextView.setSelection(0, mQueryTextView.length());
} else {
mQueryTextView.setSelection(mQueryTextView.length());
}
}
protected void showSourceSelectorDialog() {
showDialog(DIALOG_SOURCE_SELECTOR);
}
@Override
protected Dialog onCreateDialog(int id, Bundle args) {
switch (id) {
case DIALOG_SOURCE_SELECTOR:
return createSourceSelectorDialog();
default:
throw new IllegalArgumentException("Unknown dialog: " + id);
}
}
@Override
protected void onPrepareDialog(int id, Dialog dialog, Bundle args) {
switch (id) {
case DIALOG_SOURCE_SELECTOR:
prepareSourceSelectorDialog((SelectSearchSourceDialog) dialog);
break;
default:
throw new IllegalArgumentException("Unknown dialog: " + id);
}
}
protected SelectSearchSourceDialog createSourceSelectorDialog() {
SelectSearchSourceDialog dialog = new SelectSearchSourceDialog(this);
dialog.setOwnerActivity(this);
return dialog;
}
protected void prepareSourceSelectorDialog(SelectSearchSourceDialog dialog) {
dialog.setSource(getSourceName());
dialog.setQuery(getQuery());
dialog.setAppData(mAppSearchData);
}
protected void onSearchClicked(int method) {
String query = getQuery();
if (DBG) Log.d(TAG, "Search clicked, query=" + query);
mTookAction = true;
getLogger().logSearch(mSource, method, query.length());
mLauncher.startSearch(mSource, query);
}
protected void onVoiceSearchClicked() {
if (DBG) Log.d(TAG, "Voice Search clicked");
mTookAction = true;
getLogger().logVoiceSearch(mSource);
// TODO: should this start voice search in the current source?
mLauncher.startVoiceSearch();
}
protected boolean launchSuggestion(SuggestionPosition suggestion) {
return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null);
}
protected boolean launchSuggestion(SuggestionPosition suggestion,
int actionKey, String actionMsg) {
if (DBG) Log.d(TAG, "Launching suggestion " + suggestion);
mTookAction = true;
SuggestionCursor suggestions = mSuggestionsAdapter.getCurrentSuggestions();
// TODO: This should be just the queried sources, but currently
// all sources are queried
ArrayList<Source> sources = getSuggestionsProvider().getOrderedSources();
getLogger().logSuggestionClick(suggestion.getPosition(), suggestions, sources);
mLauncher.launchSuggestion(suggestion, actionKey, actionMsg);
getShortcutRepository().reportClick(suggestion);
return true;
}
protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
SuggestionCursor sourceResult = suggestion.getSuggestion();
if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1());
return false;
}
protected void onSuggestionSelected(SuggestionPosition suggestion) {
if (suggestion == null) {
// This happens when a suggestion has been selected with the
// dpad / trackball and then a different UI element is touched.
// Do nothing, since we want to keep the query of the selection
// in the search box.
return;
}
SuggestionCursor sourceResult = suggestion.getSuggestion();
String displayQuery = sourceResult.getSuggestionDisplayQuery();
if (DBG) {
Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1()
+ ",displayQuery="+ displayQuery);
}
if (TextUtils.isEmpty(displayQuery)) {
restoreUserQuery();
} else {
setQuery(displayQuery, false);
}
}
protected boolean onSuggestionKeyDown(SuggestionPosition suggestion,
int keyCode, KeyEvent event) {
// Treat enter or search as a click
if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
return launchSuggestion(suggestion);
}
if (keyCode == KeyEvent.KEYCODE_DPAD_UP
&& mSuggestionsView.getSelectedItemPosition() == 0) {
// Moved up from the top suggestion, restore the user query and focus query box
if (DBG) Log.d(TAG, "Up and out");
restoreUserQuery();
return false; // let the framework handle the move
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|| keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
// Moved left / right from a suggestion, keep current query, move
// focus to query box, and move cursor to far left / right
if (DBG) Log.d(TAG, "Left/right on a suggestion");
int cursorPos = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? 0 : mQueryTextView.length();
mQueryTextView.setSelection(cursorPos);
mQueryTextView.requestFocus();
// TODO: should we modify the list selection?
return true;
}
// Handle source-specified action keys
String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode);
if (actionMsg != null) {
return launchSuggestion(suggestion, keyCode, actionMsg);
}
return false;
}
protected void onSourceSelected() {
if (DBG) Log.d(TAG, "No suggestion selected");
restoreUserQuery();
}
protected int getSelectedPosition() {
return mSuggestionsView.getSelectedPosition();
}
protected SuggestionPosition getSelectedSuggestion() {
return mSuggestionsView.getSelectedSuggestion();
}
/**
* Hides the input method.
*/
protected void hideInputMethod() {
InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
if (imm != null) {
imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0);
}
}
protected void showInputMethodForQuery() {
InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(mQueryTextView, 0);
}
}
/**
* Hides the input method when the suggestions get focus.
*/
private class SuggestListFocusListener implements OnFocusChangeListener {
public void onFocusChange(View v, boolean focused) {
if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
if (focused) {
// The suggestions list got focus, hide the input method
hideInputMethod();
}
}
}
private class QueryTextViewFocusListener implements OnFocusChangeListener {
public void onFocusChange(View v, boolean focused) {
if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
if (focused) {
// The query box got focus, show the input method if the
// query box got focus?
showInputMethodForQuery();
}
}
}
private void startSearchProgress() {
// TODO: Cache animation between calls?
mSearchGoButton.setImageResource(R.drawable.searching);
Animatable animation = (Animatable) mSearchGoButton.getDrawable();
animation.start();
}
private void stopSearchProgress() {
Drawable animation = mSearchGoButton.getDrawable();
if (animation instanceof Animatable) {
// TODO: Is this needed, or is it done automatically when the
// animation is removed?
((Animatable) animation).stop();
}
mSearchGoButton.setImageResource(R.drawable.ic_btn_search);
}
private void updateSuggestions(String query) {
query = ltrim(query);
LatencyTracker latency = new LatencyTracker(TAG);
Suggestions suggestions = getSuggestionsProvider().getSuggestions(query);
latency.addEvent("getSuggestions_done");
if (!suggestions.isDone()) {
suggestions.registerDataSetObserver(new ProgressUpdater(suggestions));
startSearchProgress();
} else {
stopSearchProgress();
}
mSuggestionsAdapter.setSuggestions(suggestions);
latency.addEvent("shortcuts_shown");
long userVisibleLatency = latency.getUserVisibleLatency();
if (DBG) {
Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms.");
}
}
private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
if (!event.isSystem() && !isDpadKey(keyCode)) {
if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
if (mQueryTextView.requestFocus()) {
return mQueryTextView.dispatchKeyEvent(event);
}
}
return false;
}
private boolean isDpadKey(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_CENTER:
return true;
default:
return false;
}
}
/**
* Filters the suggestions list when the search text changes.
*/
private class SearchTextWatcher implements TextWatcher {
public void afterTextChanged(Editable s) {
if (mUpdateSuggestions) {
String query = s == null ? "" : s.toString();
setUserQuery(query);
updateSuggestions(query);
}
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}
/**
* Handles non-text keys in the query text view.
*/
private class QueryTextViewKeyListener implements View.OnKeyListener {
public boolean onKey(View view, int keyCode, KeyEvent event) {
// Handle IME search action key
if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
}
return false;
}
}
/**
* Handles key events on the search and voice search buttons,
* by refocusing to EditText.
*/
private class ButtonsKeyListener implements View.OnKeyListener {
public boolean onKey(View v, int keyCode, KeyEvent event) {
return forwardKeyToQueryTextView(keyCode, event);
}
}
/**
* Handles key events on the suggestions list view.
*/
private class SuggestionsViewKeyListener implements View.OnKeyListener {
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
SuggestionPosition suggestion = getSelectedSuggestion();
if (suggestion != null) {
if (onSuggestionKeyDown(suggestion, keyCode, event)) {
return true;
}
}
}
return forwardKeyToQueryTextView(keyCode, event);
}
}
private class InputMethodCloser implements SuggestionsView.InteractionListener {
public void onInteraction() {
hideInputMethod();
}
}
private class ClickHandler implements SuggestionClickListener {
public void onSuggestionClicked(SuggestionPosition suggestion) {
launchSuggestion(suggestion);
}
public boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
return SearchActivity.this.onSuggestionLongClicked(suggestion);
}
}
private class SelectionHandler implements SuggestionSelectionListener {
public void onSelectionChanged(SuggestionPosition suggestion) {
onSuggestionSelected(suggestion);
}
}
/**
* Listens for clicks on the source selector.
*/
private class SearchGoButtonClickListener implements View.OnClickListener {
public void onClick(View view) {
onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
}
}
/**
* Listens for clicks on the search button.
*/
private class SourceSelectorClickListener implements View.OnClickListener {
public void onClick(View view) {
showSourceSelectorDialog();
}
}
/**
* Listens for clicks on the voice search button.
*/
private class VoiceSearchButtonClickListener implements View.OnClickListener {
public void onClick(View view) {
onVoiceSearchClicked();
}
}
/**
* Updates the progress bar when the suggestions adapter changes its progress.
*/
private class ProgressUpdater extends DataSetObserver {
private final Suggestions mSuggestions;
public ProgressUpdater(Suggestions suggestions) {
mSuggestions = suggestions;
}
@Override
public void onChanged() {
if (mSuggestions.isDone()) {
stopSearchProgress();
}
}
}
private static String ltrim(String text) {
int start = 0;
int length = text.length();
while (start < length && Character.isWhitespace(text.charAt(start))) {
start++;
}
return start > 0 ? text.substring(start, length) : text;
}
}