blob: 3dfbe7184f90bf0085c1e096da36ac1ffbb45ad6 [file] [log] [blame]
/*
* Copyright (C) 2008 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 android.app;
import static android.app.SuggestionsAdapter.getColumnString;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.SystemClock;
import android.provider.Browser;
import android.speech.RecognizerIntent;
import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.AndroidRuntimeException;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.AutoCompleteTextView;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.AdapterView.OnItemSelectedListener;
import com.android.common.Patterns;
import java.util.ArrayList;
import java.util.WeakHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Search dialog. This is controlled by the
* SearchManager and runs in the current foreground process.
*
* @hide
*/
public class SearchDialog extends Dialog implements OnItemClickListener, OnItemSelectedListener {
// Debugging support
private static final boolean DBG = false;
private static final String LOG_TAG = "SearchDialog";
private static final boolean DBG_LOG_TIMING = false;
private static final String INSTANCE_KEY_COMPONENT = "comp";
private static final String INSTANCE_KEY_APPDATA = "data";
private static final String INSTANCE_KEY_GLOBALSEARCH = "glob";
private static final String INSTANCE_KEY_STORED_COMPONENT = "sComp";
private static final String INSTANCE_KEY_STORED_APPDATA = "sData";
private static final String INSTANCE_KEY_PREVIOUS_COMPONENTS = "sPrev";
private static final String INSTANCE_KEY_USER_QUERY = "uQry";
// The extra key used in an intent to the speech recognizer for in-app voice search.
private static final String EXTRA_CALLING_PACKAGE = "calling_package";
// The string used for privateImeOptions to identify to the IME that it should not show
// a microphone button since one already exists in the search dialog.
private static final String IME_OPTION_NO_MICROPHONE = "nm";
private static final int SEARCH_PLATE_LEFT_PADDING_GLOBAL = 12;
private static final int SEARCH_PLATE_LEFT_PADDING_NON_GLOBAL = 7;
// views & widgets
private TextView mBadgeLabel;
private SearchSourceSelector mSourceSelector;
private SearchAutoComplete mSearchAutoComplete;
private Button mGoButton;
private ImageButton mVoiceButton;
private View mSearchPlate;
private Drawable mWorkingSpinner;
// interaction with searchable application
private SearchableInfo mSearchable;
private ComponentName mLaunchComponent;
private Bundle mAppSearchData;
private boolean mGlobalSearchMode;
private Context mActivityContext;
private SearchManager mSearchManager;
// Values we store to allow user to toggle between in-app search and global search.
private ComponentName mStoredComponentName;
private Bundle mStoredAppSearchData;
// stack of previous searchables, to support the BACK key after
// SearchManager.INTENT_ACTION_CHANGE_SEARCH_SOURCE.
// The top of the stack (= previous searchable) is the last element of the list,
// since adding and removing is efficient at the end of an ArrayList.
private ArrayList<ComponentName> mPreviousComponents;
// For voice searching
private final Intent mVoiceWebSearchIntent;
private final Intent mVoiceAppSearchIntent;
// support for AutoCompleteTextView suggestions display
private SuggestionsAdapter mSuggestionsAdapter;
// Whether to rewrite queries when selecting suggestions
private static final boolean REWRITE_QUERIES = true;
// The query entered by the user. This is not changed when selecting a suggestion
// that modifies the contents of the text field. But if the user then edits
// the suggestion, the resulting string is saved.
private String mUserQuery;
// A weak map of drawables we've gotten from other packages, so we don't load them
// more than once.
private final WeakHashMap<String, Drawable.ConstantState> mOutsideDrawablesCache =
new WeakHashMap<String, Drawable.ConstantState>();
// Last known IME options value for the search edit text.
private int mSearchAutoCompleteImeOptions;
/**
* Constructor - fires it up and makes it look like the search UI.
*
* @param context Application Context we can use for system acess
*/
public SearchDialog(Context context, SearchManager searchManager) {
super(context, com.android.internal.R.style.Theme_GlobalSearchBar);
// Save voice intent for later queries/launching
mVoiceWebSearchIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH);
mVoiceWebSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mVoiceWebSearchIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH);
mVoiceAppSearchIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
mVoiceAppSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mSearchManager = searchManager;
}
/**
* Create the search dialog and any resources that are used for the
* entire lifetime of the dialog.
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Window theWindow = getWindow();
WindowManager.LayoutParams lp = theWindow.getAttributes();
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
// taking up the whole window (even when transparent) is less than ideal,
// but necessary to show the popup window until the window manager supports
// having windows anchored by their parent but not clipped by them.
lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
lp.gravity = Gravity.TOP | Gravity.FILL_HORIZONTAL;
lp.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
theWindow.setAttributes(lp);
// Touching outside of the search dialog will dismiss it
setCanceledOnTouchOutside(true);
}
/**
* We recreate the dialog view each time it becomes visible so as to limit
* the scope of any problems with the contained resources.
*/
private void createContentView() {
setContentView(com.android.internal.R.layout.search_bar);
// get the view elements for local access
SearchBar searchBar = (SearchBar) findViewById(com.android.internal.R.id.search_bar);
searchBar.setSearchDialog(this);
mBadgeLabel = (TextView) findViewById(com.android.internal.R.id.search_badge);
mSearchAutoComplete = (SearchAutoComplete)
findViewById(com.android.internal.R.id.search_src_text);
mSourceSelector = new SearchSourceSelector(
findViewById(com.android.internal.R.id.search_source_selector));
mGoButton = (Button) findViewById(com.android.internal.R.id.search_go_btn);
mVoiceButton = (ImageButton) findViewById(com.android.internal.R.id.search_voice_btn);
mSearchPlate = findViewById(com.android.internal.R.id.search_plate);
mWorkingSpinner = getContext().getResources().
getDrawable(com.android.internal.R.drawable.search_spinner);
mSearchAutoComplete.setCompoundDrawablesWithIntrinsicBounds(
null, null, mWorkingSpinner, null);
setWorking(false);
// attach listeners
mSearchAutoComplete.addTextChangedListener(mTextWatcher);
mSearchAutoComplete.setOnKeyListener(mTextKeyListener);
mSearchAutoComplete.setOnItemClickListener(this);
mSearchAutoComplete.setOnItemSelectedListener(this);
mGoButton.setOnClickListener(mGoButtonClickListener);
mGoButton.setOnKeyListener(mButtonsKeyListener);
mVoiceButton.setOnClickListener(mVoiceButtonClickListener);
mVoiceButton.setOnKeyListener(mButtonsKeyListener);
// pre-hide all the extraneous elements
mBadgeLabel.setVisibility(View.GONE);
// Additional adjustments to make Dialog work for Search
mSearchAutoCompleteImeOptions = mSearchAutoComplete.getImeOptions();
}
/**
* Set up the search dialog
*
* @return true if search dialog launched, false if not
*/
public boolean show(String initialQuery, boolean selectInitialQuery,
ComponentName componentName, Bundle appSearchData, boolean globalSearch) {
// Reset any stored values from last time dialog was shown.
mStoredComponentName = null;
mStoredAppSearchData = null;
boolean success = doShow(initialQuery, selectInitialQuery, componentName, appSearchData,
globalSearch);
if (success) {
// Display the drop down as soon as possible instead of waiting for the rest of the
// pending UI stuff to get done, so that things appear faster to the user.
mSearchAutoComplete.showDropDownAfterLayout();
}
return success;
}
private boolean isInRealAppSearch() {
return !mGlobalSearchMode
&& (mPreviousComponents == null || mPreviousComponents.isEmpty());
}
/**
* Called in response to a press of the hard search button in
* {@link #onKeyDown(int, KeyEvent)}, this method toggles between in-app
* search and global search when relevant.
*
* If pressed within an in-app search context, this switches the search dialog out to
* global search. If pressed within a global search context that was originally an in-app
* search context, this switches back to the in-app search context. If pressed within a
* global search context that has no original in-app search context (e.g., global search
* from Home), this does nothing.
*
* @return false if we wanted to toggle context but could not do so successfully, true
* in all other cases
*/
private boolean toggleGlobalSearch() {
String currentSearchText = mSearchAutoComplete.getText().toString();
if (!mGlobalSearchMode) {
mStoredComponentName = mLaunchComponent;
mStoredAppSearchData = mAppSearchData;
// If this is the browser, we have a special case to not show the icon to the left
// of the text field, for extra space for url entry (this should be reconciled in
// Eclair). So special case a second tap of the search button to remove any
// already-entered text so that we can be sure to show the "Quick Search Box" hint
// text to still make it clear to the user that we've jumped out to global search.
//
// TODO: When the browser icon issue is reconciled in Eclair, remove this special case.
if (isBrowserSearch()) currentSearchText = "";
cancel();
mSearchManager.startGlobalSearch(currentSearchText, false, mStoredAppSearchData);
return true;
} else {
if (mStoredComponentName != null) {
// This means we should toggle *back* to an in-app search context from
// global search.
return doShow(currentSearchText, false, mStoredComponentName,
mStoredAppSearchData, false);
} else {
return true;
}
}
}
/**
* Does the rest of the work required to show the search dialog. Called by both
* {@link #show(String, boolean, ComponentName, Bundle, boolean)} and
* {@link #toggleGlobalSearch()}.
*
* @return true if search dialog showed, false if not
*/
private boolean doShow(String initialQuery, boolean selectInitialQuery,
ComponentName componentName, Bundle appSearchData,
boolean globalSearch) {
// set up the searchable and show the dialog
if (!show(componentName, appSearchData, globalSearch)) {
return false;
}
// finally, load the user's initial text (which may trigger suggestions)
setUserQuery(initialQuery);
if (selectInitialQuery) {
mSearchAutoComplete.selectAll();
}
return true;
}
/**
* Sets up the search dialog and shows it.
*
* @return <code>true</code> if search dialog launched
*/
private boolean show(ComponentName componentName, Bundle appSearchData,
boolean globalSearch) {
if (DBG) {
Log.d(LOG_TAG, "show(" + componentName + ", "
+ appSearchData + ", " + globalSearch + ")");
}
SearchManager searchManager = (SearchManager)
mContext.getSystemService(Context.SEARCH_SERVICE);
// Try to get the searchable info for the provided component (or for global search,
// if globalSearch == true).
mSearchable = searchManager.getSearchableInfo(componentName, globalSearch);
// If we got back nothing, and it wasn't a request for global search, then try again
// for global search, as we'll try to launch that in lieu of any component-specific search.
if (!globalSearch && mSearchable == null) {
globalSearch = true;
mSearchable = searchManager.getSearchableInfo(componentName, globalSearch);
}
// If there's not even a searchable info available for global search, then really give up.
if (mSearchable == null) {
Log.w(LOG_TAG, "No global search provider.");
return false;
}
mLaunchComponent = componentName;
mAppSearchData = appSearchData;
// Using globalSearch here is just an optimization, just calling
// isDefaultSearchable() should always give the same result.
mGlobalSearchMode = globalSearch || searchManager.isDefaultSearchable(mSearchable);
mActivityContext = mSearchable.getActivityContext(getContext());
// show the dialog. this will call onStart().
if (!isShowing()) {
// Recreate the search bar view every time the dialog is shown, to get rid
// of any bad state in the AutoCompleteTextView etc
createContentView();
// The Dialog uses a ContextThemeWrapper for the context; use this to change the
// theme out from underneath us, between the global search theme and the in-app
// search theme. They are identical except that the global search theme does not
// dim the background of the window (because global search is full screen so it's
// not needed and this should save a little bit of time on global search invocation).
Object context = getContext();
if (context instanceof ContextThemeWrapper) {
ContextThemeWrapper wrapper = (ContextThemeWrapper) context;
if (globalSearch) {
wrapper.setTheme(com.android.internal.R.style.Theme_GlobalSearchBar);
} else {
wrapper.setTheme(com.android.internal.R.style.Theme_SearchBar);
}
}
show();
}
updateUI();
return true;
}
/**
* The search dialog is being dismissed, so handle all of the local shutdown operations.
*
* This function is designed to be idempotent so that dismiss() can be safely called at any time
* (even if already closed) and more likely to really dump any memory. No leaks!
*/
@Override
public void onStop() {
super.onStop();
closeSuggestionsAdapter();
// dump extra memory we're hanging on to
mLaunchComponent = null;
mAppSearchData = null;
mSearchable = null;
mActivityContext = null;
mUserQuery = null;
mPreviousComponents = null;
}
/**
* Sets the search dialog to the 'working' state, which shows a working spinner in the
* right hand size of the text field.
*
* @param working true to show spinner, false to hide spinner
*/
public void setWorking(boolean working) {
mWorkingSpinner.setAlpha(working ? 255 : 0);
mWorkingSpinner.setVisible(working, false);
mWorkingSpinner.invalidateSelf();
}
/**
* Closes and gets rid of the suggestions adapter.
*/
private void closeSuggestionsAdapter() {
// remove the adapter from the autocomplete first, to avoid any updates
// when we drop the cursor
mSearchAutoComplete.setAdapter((SuggestionsAdapter)null);
// close any leftover cursor
if (mSuggestionsAdapter != null) {
mSuggestionsAdapter.close();
}
mSuggestionsAdapter = null;
}
/**
* Save the minimal set of data necessary to recreate the search
*
* @return A bundle with the state of the dialog, or {@code null} if the search
* dialog is not showing.
*/
@Override
public Bundle onSaveInstanceState() {
if (!isShowing()) return null;
Bundle bundle = new Bundle();
// setup info so I can recreate this particular search
bundle.putParcelable(INSTANCE_KEY_COMPONENT, mLaunchComponent);
bundle.putBundle(INSTANCE_KEY_APPDATA, mAppSearchData);
bundle.putBoolean(INSTANCE_KEY_GLOBALSEARCH, mGlobalSearchMode);
bundle.putParcelable(INSTANCE_KEY_STORED_COMPONENT, mStoredComponentName);
bundle.putBundle(INSTANCE_KEY_STORED_APPDATA, mStoredAppSearchData);
bundle.putParcelableArrayList(INSTANCE_KEY_PREVIOUS_COMPONENTS, mPreviousComponents);
bundle.putString(INSTANCE_KEY_USER_QUERY, mUserQuery);
return bundle;
}
/**
* Restore the state of the dialog from a previously saved bundle.
*
* TODO: go through this and make sure that it saves everything that is saved
*
* @param savedInstanceState The state of the dialog previously saved by
* {@link #onSaveInstanceState()}.
*/
@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
ComponentName launchComponent = savedInstanceState.getParcelable(INSTANCE_KEY_COMPONENT);
Bundle appSearchData = savedInstanceState.getBundle(INSTANCE_KEY_APPDATA);
boolean globalSearch = savedInstanceState.getBoolean(INSTANCE_KEY_GLOBALSEARCH);
ComponentName storedComponentName =
savedInstanceState.getParcelable(INSTANCE_KEY_STORED_COMPONENT);
Bundle storedAppSearchData =
savedInstanceState.getBundle(INSTANCE_KEY_STORED_APPDATA);
ArrayList<ComponentName> previousComponents =
savedInstanceState.getParcelableArrayList(INSTANCE_KEY_PREVIOUS_COMPONENTS);
String userQuery = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY);
// Set stored state
mStoredComponentName = storedComponentName;
mStoredAppSearchData = storedAppSearchData;
mPreviousComponents = previousComponents;
// show the dialog.
if (!doShow(userQuery, false, launchComponent, appSearchData, globalSearch)) {
// for some reason, we couldn't re-instantiate
return;
}
}
/**
* Called after resources have changed, e.g. after screen rotation or locale change.
*/
public void onConfigurationChanged() {
if (isShowing()) {
// Redraw (resources may have changed)
updateSearchButton();
updateSearchAppIcon();
updateSearchBadge();
updateQueryHint();
mSearchAutoComplete.showDropDownAfterLayout();
}
}
/**
* Update the UI according to the info in the current value of {@link #mSearchable}.
*/
private void updateUI() {
if (mSearchable != null) {
mDecor.setVisibility(View.VISIBLE);
updateSearchAutoComplete();
updateSearchButton();
updateSearchAppIcon();
updateSearchBadge();
updateQueryHint();
updateVoiceButton();
// In order to properly configure the input method (if one is being used), we
// need to let it know if we'll be providing suggestions. Although it would be
// difficult/expensive to know if every last detail has been configured properly, we
// can at least see if a suggestions provider has been configured, and use that
// as our trigger.
int inputType = mSearchable.getInputType();
// We only touch this if the input type is set up for text (which it almost certainly
// should be, in the case of search!)
if ((inputType & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT) {
// The existence of a suggestions authority is the proxy for "suggestions
// are available here"
inputType &= ~InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE;
if (mSearchable.getSuggestAuthority() != null) {
inputType |= InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE;
}
}
mSearchAutoComplete.setInputType(inputType);
mSearchAutoCompleteImeOptions = mSearchable.getImeOptions();
mSearchAutoComplete.setImeOptions(mSearchAutoCompleteImeOptions);
// If the search dialog is going to show a voice search button, then don't let
// the soft keyboard display a microphone button if it would have otherwise.
if (mSearchable.getVoiceSearchEnabled()) {
mSearchAutoComplete.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
} else {
mSearchAutoComplete.setPrivateImeOptions(null);
}
}
}
/**
* Updates the auto-complete text view.
*/
private void updateSearchAutoComplete() {
// close any existing suggestions adapter
closeSuggestionsAdapter();
mSearchAutoComplete.setDropDownAnimationStyle(0); // no animation
mSearchAutoComplete.setThreshold(mSearchable.getSuggestThreshold());
// we dismiss the entire dialog instead
mSearchAutoComplete.setDropDownDismissedOnCompletion(false);
if (!isInRealAppSearch()) {
mSearchAutoComplete.setDropDownAlwaysVisible(true); // fill space until results come in
} else {
mSearchAutoComplete.setDropDownAlwaysVisible(false);
}
mSearchAutoComplete.setForceIgnoreOutsideTouch(true);
// attach the suggestions adapter, if suggestions are available
// The existence of a suggestions authority is the proxy for "suggestions available here"
if (mSearchable.getSuggestAuthority() != null) {
mSuggestionsAdapter = new SuggestionsAdapter(getContext(), this, mSearchable,
mOutsideDrawablesCache, mGlobalSearchMode);
mSearchAutoComplete.setAdapter(mSuggestionsAdapter);
}
}
/**
* Update the text in the search button. Note: This is deprecated functionality, for
* 1.0 compatibility only.
*/
private void updateSearchButton() {
String textLabel = null;
Drawable iconLabel = null;
int textId = mSearchable.getSearchButtonText();
if (textId != 0) {
textLabel = mActivityContext.getResources().getString(textId);
} else {
iconLabel = getContext().getResources().
getDrawable(com.android.internal.R.drawable.ic_btn_search);
}
mGoButton.setText(textLabel);
mGoButton.setCompoundDrawablesWithIntrinsicBounds(iconLabel, null, null, null);
}
private void updateSearchAppIcon() {
mSourceSelector.setSource(mSearchable.getSearchActivity());
mSourceSelector.setAppSearchData(mAppSearchData);
// In Donut, we special-case the case of the browser to hide the app icon as if it were
// global search, for extra space for url entry.
//
// TODO: Remove this special case once the issue has been reconciled in Eclair.
if (mGlobalSearchMode || isBrowserSearch()) {
mSourceSelector.setSourceIcon(null);
mSourceSelector.setVisibility(View.GONE);
mSearchPlate.setPadding(SEARCH_PLATE_LEFT_PADDING_GLOBAL,
mSearchPlate.getPaddingTop(),
mSearchPlate.getPaddingRight(),
mSearchPlate.getPaddingBottom());
} else {
PackageManager pm = getContext().getPackageManager();
Drawable icon;
try {
ActivityInfo info = pm.getActivityInfo(mLaunchComponent, 0);
icon = pm.getApplicationIcon(info.applicationInfo);
if (DBG) Log.d(LOG_TAG, "Using app-specific icon");
} catch (NameNotFoundException e) {
icon = pm.getDefaultActivityIcon();
Log.w(LOG_TAG, mLaunchComponent + " not found, using generic app icon");
}
mSourceSelector.setSourceIcon(icon);
mSourceSelector.setVisibility(View.VISIBLE);
mSearchPlate.setPadding(SEARCH_PLATE_LEFT_PADDING_NON_GLOBAL,
mSearchPlate.getPaddingTop(),
mSearchPlate.getPaddingRight(),
mSearchPlate.getPaddingBottom());
}
}
/**
* Setup the search "Badge" if requested by mode flags.
*/
private void updateSearchBadge() {
// assume both hidden
int visibility = View.GONE;
Drawable icon = null;
CharSequence text = null;
// optionally show one or the other.
if (mSearchable.useBadgeIcon()) {
icon = mActivityContext.getResources().getDrawable(mSearchable.getIconId());
visibility = View.VISIBLE;
if (DBG) Log.d(LOG_TAG, "Using badge icon: " + mSearchable.getIconId());
} else if (mSearchable.useBadgeLabel()) {
text = mActivityContext.getResources().getText(mSearchable.getLabelId()).toString();
visibility = View.VISIBLE;
if (DBG) Log.d(LOG_TAG, "Using badge label: " + mSearchable.getLabelId());
}
mBadgeLabel.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
mBadgeLabel.setText(text);
mBadgeLabel.setVisibility(visibility);
}
/**
* Update the hint in the query text field.
*/
private void updateQueryHint() {
if (isShowing()) {
String hint = null;
if (mSearchable != null) {
int hintId = mSearchable.getHintId();
if (hintId != 0) {
hint = mActivityContext.getString(hintId);
}
}
mSearchAutoComplete.setHint(hint);
}
}
/**
* Update the visibility of the voice button. There are actually two voice search modes,
* either of which will activate the button.
*/
private void updateVoiceButton() {
int visibility = View.GONE;
if (mSearchable.getVoiceSearchEnabled()) {
Intent testIntent = null;
if (mSearchable.getVoiceSearchLaunchWebSearch()) {
testIntent = mVoiceWebSearchIntent;
} else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
testIntent = mVoiceAppSearchIntent;
}
if (testIntent != null) {
ResolveInfo ri = getContext().getPackageManager().
resolveActivity(testIntent, PackageManager.MATCH_DEFAULT_ONLY);
if (ri != null) {
visibility = View.VISIBLE;
}
}
}
mVoiceButton.setVisibility(visibility);
}
/**
* Hack to determine whether this is the browser, so we can remove the browser icon
* to the left of the search field, as a special requirement for Donut.
*
* TODO: For Eclair, reconcile this with the rest of the global search UI.
*/
private boolean isBrowserSearch() {
return mLaunchComponent.flattenToShortString().startsWith("com.android.browser/");
}
/**
* Listeners of various types
*/
/**
* {@link Dialog#onTouchEvent(MotionEvent)} will cancel the dialog only when the
* touch is outside the window. But the window includes space for the drop-down,
* so we also cancel on taps outside the search bar when the drop-down is not showing.
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
// cancel if the drop-down is not showing and the touch event was outside the search plate
if (!mSearchAutoComplete.isPopupShowing() && isOutOfBounds(mSearchPlate, event)) {
if (DBG) Log.d(LOG_TAG, "Pop-up not showing and outside of search plate.");
cancel();
return true;
}
// Let Dialog handle events outside the window while the pop-up is showing.
return super.onTouchEvent(event);
}
private boolean isOutOfBounds(View v, MotionEvent event) {
final int x = (int) event.getX();
final int y = (int) event.getY();
final int slop = ViewConfiguration.get(mContext).getScaledWindowTouchSlop();
return (x < -slop) || (y < -slop)
|| (x > (v.getWidth()+slop))
|| (y > (v.getHeight()+slop));
}
/**
* Dialog's OnKeyListener implements various search-specific functionality
*
* @param keyCode This is the keycode of the typed key, and is the same value as
* found in the KeyEvent parameter.
* @param event The complete event record for the typed key
*
* @return Return true if the event was handled here, or false if not.
*/
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (DBG) Log.d(LOG_TAG, "onKeyDown(" + keyCode + "," + event + ")");
if (mSearchable == null) {
return false;
}
if (keyCode == KeyEvent.KEYCODE_SEARCH && event.getRepeatCount() == 0) {
event.startTracking();
// Consume search key for later use.
return true;
}
// if it's an action specified by the searchable activity, launch the
// entered query with the action key
SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) {
launchQuerySearch(keyCode, actionKey.getQueryActionMsg());
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (DBG) Log.d(LOG_TAG, "onKeyUp(" + keyCode + "," + event + ")");
if (mSearchable == null) {
return false;
}
if (keyCode == KeyEvent.KEYCODE_SEARCH && event.isTracking()
&& !event.isCanceled()) {
// If the search key is pressed, toggle between global and in-app search. If we are
// currently doing global search and there is no in-app search context to toggle to,
// just don't do anything.
return toggleGlobalSearch();
}
return super.onKeyUp(keyCode, event);
}
/**
* Callback to watch the textedit field for empty/non-empty
*/
private TextWatcher mTextWatcher = new TextWatcher() {
public void beforeTextChanged(CharSequence s, int start, int before, int after) { }
public void onTextChanged(CharSequence s, int start,
int before, int after) {
if (DBG_LOG_TIMING) {
dbgLogTiming("onTextChanged()");
}
if (mSearchable == null) {
return;
}
updateWidgetState();
if (!mSearchAutoComplete.isPerformingCompletion()) {
// The user changed the query, remember it.
mUserQuery = s == null ? "" : s.toString();
mSourceSelector.setQuery(mUserQuery);
}
}
public void afterTextChanged(Editable s) {
if (mSearchable == null) {
return;
}
if (mSearchable.autoUrlDetect() && !mSearchAutoComplete.isPerformingCompletion()) {
// The user changed the query, check if it is a URL and if so change the search
// button in the soft keyboard to the 'Go' button.
int options = (mSearchAutoComplete.getImeOptions() & (~EditorInfo.IME_MASK_ACTION));
if (Patterns.WEB_URL.matcher(mUserQuery).matches()) {
options = options | EditorInfo.IME_ACTION_GO;
} else {
options = options | EditorInfo.IME_ACTION_SEARCH;
}
if (options != mSearchAutoCompleteImeOptions) {
mSearchAutoCompleteImeOptions = options;
mSearchAutoComplete.setImeOptions(options);
// This call is required to update the soft keyboard UI with latest IME flags.
mSearchAutoComplete.setInputType(mSearchAutoComplete.getInputType());
}
}
}
};
/**
* Enable/Disable the cancel button based on edit text state (any text?)
*/
private void updateWidgetState() {
// enable the button if we have one or more non-space characters
boolean enabled = !mSearchAutoComplete.isEmpty();
mGoButton.setEnabled(enabled);
mGoButton.setFocusable(enabled);
}
/**
* React to typing in the GO search button by refocusing to EditText.
* Continue typing the query.
*/
View.OnKeyListener mButtonsKeyListener = new View.OnKeyListener() {
public boolean onKey(View v, int keyCode, KeyEvent event) {
// guard against possible race conditions
if (mSearchable == null) {
return false;
}
if (!event.isSystem() &&
(keyCode != KeyEvent.KEYCODE_DPAD_UP) &&
(keyCode != KeyEvent.KEYCODE_DPAD_LEFT) &&
(keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) &&
(keyCode != KeyEvent.KEYCODE_DPAD_CENTER)) {
// restore focus and give key to EditText ...
if (mSearchAutoComplete.requestFocus()) {
return mSearchAutoComplete.dispatchKeyEvent(event);
}
}
return false;
}
};
/**
* React to a click in the GO button by launching a search.
*/
View.OnClickListener mGoButtonClickListener = new View.OnClickListener() {
public void onClick(View v) {
// guard against possible race conditions
if (mSearchable == null) {
return;
}
launchQuerySearch();
}
};
/**
* React to a click in the voice search button.
*/
View.OnClickListener mVoiceButtonClickListener = new View.OnClickListener() {
public void onClick(View v) {
// guard against possible race conditions
if (mSearchable == null) {
return;
}
try {
// First stop the existing search before starting voice search, or else we'll end
// up showing the search dialog again once we return to the app.
((SearchManager) getContext().getSystemService(Context.SEARCH_SERVICE)).
stopSearch();
if (mSearchable.getVoiceSearchLaunchWebSearch()) {
getContext().startActivity(mVoiceWebSearchIntent);
} else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
Intent appSearchIntent = createVoiceAppSearchIntent(mVoiceAppSearchIntent);
getContext().startActivity(appSearchIntent);
}
} catch (ActivityNotFoundException e) {
// Should not happen, since we check the availability of
// voice search before showing the button. But just in case...
Log.w(LOG_TAG, "Could not find voice search activity");
}
}
};
/**
* Create and return an Intent that can launch the voice search activity, perform a specific
* voice transcription, and forward the results to the searchable activity.
*
* @param baseIntent The voice app search intent to start from
* @return A completely-configured intent ready to send to the voice search activity
*/
private Intent createVoiceAppSearchIntent(Intent baseIntent) {
ComponentName searchActivity = mSearchable.getSearchActivity();
// create the necessary intent to set up a search-and-forward operation
// in the voice search system. We have to keep the bundle separate,
// because it becomes immutable once it enters the PendingIntent
Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
queryIntent.setComponent(searchActivity);
PendingIntent pending = PendingIntent.getActivity(
getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT);
// Now set up the bundle that will be inserted into the pending intent
// when it's time to do the search. We always build it here (even if empty)
// because the voice search activity will always need to insert "QUERY" into
// it anyway.
Bundle queryExtras = new Bundle();
if (mAppSearchData != null) {
queryExtras.putBundle(SearchManager.APP_DATA, mAppSearchData);
}
// Now build the intent to launch the voice search. Add all necessary
// extras to launch the voice recognizer, and then all the necessary extras
// to forward the results to the searchable activity
Intent voiceIntent = new Intent(baseIntent);
// Add all of the configuration options supplied by the searchable's metadata
String languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
String prompt = null;
String language = null;
int maxResults = 1;
Resources resources = mActivityContext.getResources();
if (mSearchable.getVoiceLanguageModeId() != 0) {
languageModel = resources.getString(mSearchable.getVoiceLanguageModeId());
}
if (mSearchable.getVoicePromptTextId() != 0) {
prompt = resources.getString(mSearchable.getVoicePromptTextId());
}
if (mSearchable.getVoiceLanguageId() != 0) {
language = resources.getString(mSearchable.getVoiceLanguageId());
}
if (mSearchable.getVoiceMaxResults() != 0) {
maxResults = mSearchable.getVoiceMaxResults();
}
voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
voiceIntent.putExtra(EXTRA_CALLING_PACKAGE,
searchActivity == null ? null : searchActivity.toShortString());
// Add the values that configure forwarding the results
voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
return voiceIntent;
}
/**
* Corrects http/https typo errors in the given url string, and if the protocol specifier was
* not present defaults to http.
*
* @param inUrl URL to check and fix
* @return fixed URL string.
*/
private String fixUrl(String inUrl) {
if (inUrl.startsWith("http://") || inUrl.startsWith("https://"))
return inUrl;
if (inUrl.startsWith("http:") || inUrl.startsWith("https:")) {
if (inUrl.startsWith("http:/") || inUrl.startsWith("https:/")) {
inUrl = inUrl.replaceFirst("/", "//");
} else {
inUrl = inUrl.replaceFirst(":", "://");
}
}
if (inUrl.indexOf("://") == -1) {
inUrl = "http://" + inUrl;
}
return inUrl;
}
/**
* React to the user typing "enter" or other hardwired keys while typing in the search box.
* This handles these special keys while the edit box has focus.
*/
View.OnKeyListener mTextKeyListener = new View.OnKeyListener() {
public boolean onKey(View v, int keyCode, KeyEvent event) {
// guard against possible race conditions
if (mSearchable == null) {
return false;
}
if (DBG_LOG_TIMING) dbgLogTiming("doTextKey()");
if (DBG) {
Log.d(LOG_TAG, "mTextListener.onKey(" + keyCode + "," + event
+ "), selection: " + mSearchAutoComplete.getListSelection());
}
// If a suggestion is selected, handle enter, search key, and action keys
// as presses on the selected suggestion
if (mSearchAutoComplete.isPopupShowing() &&
mSearchAutoComplete.getListSelection() != ListView.INVALID_POSITION) {
return onSuggestionsKey(v, keyCode, event);
}
// If there is text in the query box, handle enter, and action keys
// The search key is handled by the dialog's onKeyDown().
if (!mSearchAutoComplete.isEmpty()) {
if (keyCode == KeyEvent.KEYCODE_ENTER
&& event.getAction() == KeyEvent.ACTION_UP) {
v.cancelLongPress();
// If this is a url entered by the user & we displayed the 'Go' button which
// the user clicked, launch the url instead of using it as a search query.
if (mSearchable.autoUrlDetect() &&
(mSearchAutoCompleteImeOptions & EditorInfo.IME_MASK_ACTION)
== EditorInfo.IME_ACTION_GO) {
Uri uri = Uri.parse(fixUrl(mSearchAutoComplete.getText().toString()));
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
launchIntent(intent);
} else {
// Launch as a regular search.
launchQuerySearch();
}
return true;
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) {
launchQuerySearch(keyCode, actionKey.getQueryActionMsg());
return true;
}
}
}
return false;
}
};
@Override
public void hide() {
if (!isShowing()) return;
// We made sure the IME was displayed, so also make sure it is closed
// when we go away.
InputMethodManager imm = (InputMethodManager)getContext()
.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.hideSoftInputFromWindow(
getWindow().getDecorView().getWindowToken(), 0);
}
super.hide();
}
/**
* React to the user typing while in the suggestions list. First, check for action
* keys. If not handled, try refocusing regular characters into the EditText.
*/
private boolean onSuggestionsKey(View v, int keyCode, KeyEvent event) {
// guard against possible race conditions (late arrival after dismiss)
if (mSearchable == null) {
return false;
}
if (mSuggestionsAdapter == null) {
return false;
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (DBG_LOG_TIMING) {
dbgLogTiming("onSuggestionsKey()");
}
// First, check for enter or search (both of which we'll treat as a "click")
if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
int position = mSearchAutoComplete.getListSelection();
return launchSuggestion(position);
}
// Next, check for left/right moves, which we use to "return" the user to the edit view
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
// give "focus" to text editor, with cursor at the beginning if
// left key, at end if right key
// TODO: Reverse left/right for right-to-left languages, e.g. Arabic
int selPoint = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ?
0 : mSearchAutoComplete.length();
mSearchAutoComplete.setSelection(selPoint);
mSearchAutoComplete.setListSelection(0);
mSearchAutoComplete.clearListSelection();
mSearchAutoComplete.ensureImeVisible();
return true;
}
// Next, check for an "up and out" move
if (keyCode == KeyEvent.KEYCODE_DPAD_UP
&& 0 == mSearchAutoComplete.getListSelection()) {
restoreUserQuery();
// let ACTV complete the move
return false;
}
// Next, check for an "action key"
SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
if ((actionKey != null) &&
((actionKey.getSuggestActionMsg() != null) ||
(actionKey.getSuggestActionMsgColumn() != null))) {
// launch suggestion using action key column
int position = mSearchAutoComplete.getListSelection();
if (position != ListView.INVALID_POSITION) {
Cursor c = mSuggestionsAdapter.getCursor();
if (c.moveToPosition(position)) {
final String actionMsg = getActionKeyMessage(c, actionKey);
if (actionMsg != null && (actionMsg.length() > 0)) {
return launchSuggestion(position, keyCode, actionMsg);
}
}
}
}
}
return false;
}
/**
* Launch a search for the text in the query text field.
*/
public void launchQuerySearch() {
launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null);
}
/**
* Launch a search for the text in the query text field.
*
* @param actionKey The key code of the action key that was pressed,
* or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
* @param actionMsg The message for the action key that was pressed,
* or <code>null</code> if none.
*/
protected void launchQuerySearch(int actionKey, String actionMsg) {
String query = mSearchAutoComplete.getText().toString();
String action = mGlobalSearchMode ? Intent.ACTION_WEB_SEARCH : Intent.ACTION_SEARCH;
Intent intent = createIntent(action, null, null, query, null,
actionKey, actionMsg, null);
// Allow GlobalSearch to log and create shortcut for searches launched by
// the search button, enter key or an action key.
if (mGlobalSearchMode) {
mSuggestionsAdapter.reportSearch(query);
}
launchIntent(intent);
}
/**
* Launches an intent based on a suggestion.
*
* @param position The index of the suggestion to create the intent from.
* @return true if a successful launch, false if could not (e.g. bad position).
*/
protected boolean launchSuggestion(int position) {
return launchSuggestion(position, KeyEvent.KEYCODE_UNKNOWN, null);
}
/**
* Launches an intent based on a suggestion.
*
* @param position The index of the suggestion to create the intent from.
* @param actionKey The key code of the action key that was pressed,
* or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
* @param actionMsg The message for the action key that was pressed,
* or <code>null</code> if none.
* @return true if a successful launch, false if could not (e.g. bad position).
*/
protected boolean launchSuggestion(int position, int actionKey, String actionMsg) {
Cursor c = mSuggestionsAdapter.getCursor();
if ((c != null) && c.moveToPosition(position)) {
Intent intent = createIntentFromSuggestion(c, actionKey, actionMsg);
// report back about the click
if (mGlobalSearchMode) {
// in global search mode, do it via cursor
mSuggestionsAdapter.callCursorOnClick(c, position, actionKey, actionMsg);
} else if (intent != null
&& mPreviousComponents != null
&& !mPreviousComponents.isEmpty()) {
// in-app search (and we have pivoted in as told by mPreviousComponents,
// which is used for keeping track of what we pop back to when we are pivoting into
// in app search.)
reportInAppClickToGlobalSearch(c, intent);
}
// launch the intent
launchIntent(intent);
return true;
}
return false;
}
/**
* Report a click from an in app search result back to global search for shortcutting porpoises.
*
* @param c The cursor that is pointing to the clicked position.
* @param intent The intent that will be launched for the click.
*/
private void reportInAppClickToGlobalSearch(Cursor c, Intent intent) {
// for in app search, still tell global search via content provider
Uri uri = getClickReportingUri();
final ContentValues cv = new ContentValues();
cv.put(SearchManager.SEARCH_CLICK_REPORT_COLUMN_QUERY, mUserQuery);
final ComponentName source = mSearchable.getSearchActivity();
cv.put(SearchManager.SEARCH_CLICK_REPORT_COLUMN_COMPONENT, source.flattenToShortString());
// grab the intent columns from the intent we created since it has additional
// logic for falling back on the searchable default
cv.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION, intent.getAction());
cv.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, intent.getDataString());
cv.put(SearchManager.SUGGEST_COLUMN_INTENT_COMPONENT_NAME,
intent.getComponent().flattenToShortString());
// ensure the icons will work for global search
cv.put(SearchManager.SUGGEST_COLUMN_ICON_1,
wrapIconForPackage(
mSearchable.getSuggestPackage(),
getColumnString(c, SearchManager.SUGGEST_COLUMN_ICON_1)));
cv.put(SearchManager.SUGGEST_COLUMN_ICON_2,
wrapIconForPackage(
mSearchable.getSuggestPackage(),
getColumnString(c, SearchManager.SUGGEST_COLUMN_ICON_2)));
// the rest can be passed through directly
cv.put(SearchManager.SUGGEST_COLUMN_FORMAT,
getColumnString(c, SearchManager.SUGGEST_COLUMN_FORMAT));
cv.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
getColumnString(c, SearchManager.SUGGEST_COLUMN_TEXT_1));
cv.put(SearchManager.SUGGEST_COLUMN_TEXT_2,
getColumnString(c, SearchManager.SUGGEST_COLUMN_TEXT_2));
cv.put(SearchManager.SUGGEST_COLUMN_QUERY,
getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY));
cv.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
getColumnString(c, SearchManager.SUGGEST_COLUMN_SHORTCUT_ID));
// note: deliberately omitting background color since it is only for global search
// "more results" entries
mContext.getContentResolver().insert(uri, cv);
}
/**
* @return A URI appropriate for reporting a click.
*/
private Uri getClickReportingUri() {
Uri.Builder uriBuilder = new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(SearchManager.SEARCH_CLICK_REPORT_AUTHORITY);
uriBuilder.appendPath(SearchManager.SEARCH_CLICK_REPORT_URI_PATH);
return uriBuilder
.query("") // TODO: Remove, workaround for a bug in Uri.writeToParcel()
.fragment("") // TODO: Remove, workaround for a bug in Uri.writeToParcel()
.build();
}
/**
* Wraps an icon for a particular package. If the icon is a resource id, it is converted into
* an android.resource:// URI.
*
* @param packageName The source of the icon
* @param icon The icon retrieved from a suggestion column
* @return An icon string appropriate for the package.
*/
private String wrapIconForPackage(String packageName, String icon) {
if (icon == null || icon.length() == 0 || "0".equals(icon)) {
// SearchManager specifies that null or zero can be returned to indicate
// no icon. We also allow empty string.
return null;
} else if (!Character.isDigit(icon.charAt(0))){
return icon;
} else {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(packageName)
.encodedPath(icon)
.toString();
}
}
/**
* Launches an intent, including any special intent handling.
*/
private void launchIntent(Intent intent) {
if (intent == null) {
return;
}
if (handleSpecialIntent(intent)){
return;
}
Log.d(LOG_TAG, "launching " + intent);
try {
// in global search mode, we send the activity straight to the original suggestion
// source. this is because GlobalSearch may not have permission to launch the
// intent, and to avoid the extra step of going through GlobalSearch.
if (mGlobalSearchMode) {
launchGlobalSearchIntent(intent);
if (mStoredComponentName != null) {
// If we're embedded in an application, dismiss the dialog.
// This ensures that if the intent is handled by the current
// activity, it's not obscured by the dialog.
dismiss();
}
} else {
// If the intent was created from a suggestion, it will always have an explicit
// component here.
Log.i(LOG_TAG, "Starting (as ourselves) " + intent.toURI());
getContext().startActivity(intent);
// If the search switches to a different activity,
// SearchDialogWrapper#performActivityResuming
// will handle hiding the dialog when the next activity starts, but for
// real in-app search, we still need to dismiss the dialog.
if (isInRealAppSearch()) {
dismiss();
}
}
} catch (RuntimeException ex) {
Log.e(LOG_TAG, "Failed launch activity: " + intent, ex);
}
}
private void launchGlobalSearchIntent(Intent intent) {
final String packageName;
// GlobalSearch puts the original source of the suggestion in the
// 'component name' column. If set, we send the intent to that activity.
// We trust GlobalSearch to always set this to the suggestion source.
String intentComponent = intent.getStringExtra(SearchManager.COMPONENT_NAME_KEY);
if (intentComponent != null) {
ComponentName componentName = ComponentName.unflattenFromString(intentComponent);
intent.setComponent(componentName);
intent.removeExtra(SearchManager.COMPONENT_NAME_KEY);
// Launch the intent as the suggestion source.
// This prevents sources from using the search dialog to launch
// intents that they don't have permission for themselves.
packageName = componentName.getPackageName();
} else {
// If there is no component in the suggestion, it must be a built-in suggestion
// from GlobalSearch (e.g. "Search the web for") or the intent
// launched when pressing the search/go button in the search dialog.
// Launch the intent with the permissions of GlobalSearch.
packageName = mSearchable.getSearchActivity().getPackageName();
}
// Launch all global search suggestions as new tasks, since they don't relate
// to the current task.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
setBrowserApplicationId(intent);
startActivityInPackage(intent, packageName);
}
/**
* If the intent is to open an HTTP or HTTPS URL, we set
* {@link Browser#EXTRA_APPLICATION_ID} so that any existing browser window that
* has been opened by us for the same URL will be reused.
*/
private void setBrowserApplicationId(Intent intent) {
Uri data = intent.getData();
if (Intent.ACTION_VIEW.equals(intent.getAction()) && data != null) {
String scheme = data.getScheme();
if (scheme != null && scheme.startsWith("http")) {
intent.putExtra(Browser.EXTRA_APPLICATION_ID, data.toString());
}
}
}
/**
* Starts an activity as if it had been started by the given package.
*
* @param intent The description of the activity to start.
* @param packageName
* @throws ActivityNotFoundException If the intent could not be resolved to
* and existing activity.
* @throws SecurityException If the package does not have permission to start
* start the activity.
* @throws AndroidRuntimeException If some other error occurs.
*/
private void startActivityInPackage(Intent intent, String packageName) {
try {
int uid = ActivityThread.getPackageManager().getPackageUid(packageName);
if (uid < 0) {
throw new AndroidRuntimeException("Package UID not found " + packageName);
}
String resolvedType = intent.resolveTypeIfNeeded(getContext().getContentResolver());
IBinder resultTo = null;
String resultWho = null;
int requestCode = -1;
boolean onlyIfNeeded = false;
Log.i(LOG_TAG, "Starting (uid " + uid + ", " + packageName + ") " + intent.toURI());
int result = ActivityManagerNative.getDefault().startActivityInPackage(
uid, intent, resolvedType, resultTo, resultWho, requestCode, onlyIfNeeded);
checkStartActivityResult(result, intent);
} catch (RemoteException ex) {
throw new AndroidRuntimeException(ex);
}
}
// Stolen from Instrumentation.checkStartActivityResult()
private static void checkStartActivityResult(int res, Intent intent) {
if (res >= IActivityManager.START_SUCCESS) {
return;
}
switch (res) {
case IActivityManager.START_INTENT_NOT_RESOLVED:
case IActivityManager.START_CLASS_NOT_FOUND:
if (intent.getComponent() != null)
throw new ActivityNotFoundException(
"Unable to find explicit activity class "
+ intent.getComponent().toShortString()
+ "; have you declared this activity in your AndroidManifest.xml?");
throw new ActivityNotFoundException(
"No Activity found to handle " + intent);
case IActivityManager.START_PERMISSION_DENIED:
throw new SecurityException("Not allowed to start activity "
+ intent);
case IActivityManager.START_FORWARD_AND_REQUEST_CONFLICT:
throw new AndroidRuntimeException(
"FORWARD_RESULT_FLAG used while also requesting a result");
default:
throw new AndroidRuntimeException("Unknown error code "
+ res + " when starting " + intent);
}
}
/**
* Handles the special intent actions declared in {@link SearchManager}.
*
* @return <code>true</code> if the intent was handled.
*/
private boolean handleSpecialIntent(Intent intent) {
String action = intent.getAction();
if (SearchManager.INTENT_ACTION_CHANGE_SEARCH_SOURCE.equals(action)) {
handleChangeSourceIntent(intent);
return true;
}
return false;
}
/**
* Handles {@link SearchManager#INTENT_ACTION_CHANGE_SEARCH_SOURCE}.
*/
private void handleChangeSourceIntent(Intent intent) {
Uri dataUri = intent.getData();
if (dataUri == null) {
Log.w(LOG_TAG, "SearchManager.INTENT_ACTION_CHANGE_SOURCE without intent data.");
return;
}
ComponentName componentName = ComponentName.unflattenFromString(dataUri.toString());
if (componentName == null) {
Log.w(LOG_TAG, "Invalid ComponentName: " + dataUri);
return;
}
if (DBG) Log.d(LOG_TAG, "Switching to " + componentName);
pushPreviousComponent(mLaunchComponent);
if (!show(componentName, mAppSearchData, false)) {
Log.w(LOG_TAG, "Failed to switch to source " + componentName);
popPreviousComponent();
return;
}
String query = intent.getStringExtra(SearchManager.QUERY);
setUserQuery(query);
mSearchAutoComplete.showDropDown();
}
/**
* Sets the list item selection in the AutoCompleteTextView's ListView.
*/
public void setListSelection(int index) {
mSearchAutoComplete.setListSelection(index);
}
/**
* Checks if there are any previous searchable components in the history stack.
*/
private boolean hasPreviousComponent() {
return mPreviousComponents != null && !mPreviousComponents.isEmpty();
}
/**
* Saves the previous component that was searched, so that we can go
* back to it.
*/
private void pushPreviousComponent(ComponentName componentName) {
if (mPreviousComponents == null) {
mPreviousComponents = new ArrayList<ComponentName>();
}
mPreviousComponents.add(componentName);
}
/**
* Pops the previous component off the stack and returns it.
*
* @return The component name, or <code>null</code> if there was
* no previous component.
*/
private ComponentName popPreviousComponent() {
if (!hasPreviousComponent()) {
return null;
}
return mPreviousComponents.remove(mPreviousComponents.size() - 1);
}
/**
* Goes back to the previous component that was searched, if any.
*
* @return <code>true</code> if there was a previous component that we could go back to.
*/
private boolean backToPreviousComponent() {
ComponentName previous = popPreviousComponent();
if (previous == null) {
return false;
}
if (!show(previous, mAppSearchData, false)) {
Log.w(LOG_TAG, "Failed to switch to source " + previous);
return false;
}
// must touch text to trigger suggestions
// TODO: should this be the text as it was when the user left
// the source that we are now going back to?
String query = mSearchAutoComplete.getText().toString();
setUserQuery(query);
return true;
}
/**
* When a particular suggestion has been selected, perform the various lookups required
* to use the suggestion. This includes checking the cursor for suggestion-specific data,
* and/or falling back to the XML for defaults; It also creates REST style Uri data when
* the suggestion includes a data id.
*
* @param c The suggestions cursor, moved to the row of the user's selection
* @param actionKey The key code of the action key that was pressed,
* or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
* @param actionMsg The message for the action key that was pressed,
* or <code>null</code> if none.
* @return An intent for the suggestion at the cursor's position.
*/
private Intent createIntentFromSuggestion(Cursor c, int actionKey, String actionMsg) {
try {
// use specific action if supplied, or default action if supplied, or fixed default
String action = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_ACTION);
// some items are display only, or have effect via the cursor respond click reporting.
if (SearchManager.INTENT_ACTION_NONE.equals(action)) {
return null;
}
if (action == null) {
action = mSearchable.getSuggestIntentAction();
}
if (action == null) {
action = Intent.ACTION_SEARCH;
}
// use specific data if supplied, or default data if supplied
String data = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
if (data == null) {
data = mSearchable.getSuggestIntentData();
}
// then, if an ID was provided, append it.
if (data != null) {
String id = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
if (id != null) {
data = data + "/" + Uri.encode(id);
}
}
Uri dataUri = (data == null) ? null : Uri.parse(data);
String componentName = getColumnString(
c, SearchManager.SUGGEST_COLUMN_INTENT_COMPONENT_NAME);
String query = getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY);
String extraData = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA);
String mode = mGlobalSearchMode ? SearchManager.MODE_GLOBAL_SEARCH_SUGGESTION : null;
return createIntent(action, dataUri, extraData, query, componentName, actionKey,
actionMsg, mode);
} catch (RuntimeException e ) {
int rowNum;
try { // be really paranoid now
rowNum = c.getPosition();
} catch (RuntimeException e2 ) {
rowNum = -1;
}
Log.w(LOG_TAG, "Search Suggestions cursor at row " + rowNum +
" returned exception" + e.toString());
return null;
}
}
/**
* Constructs an intent from the given information and the search dialog state.
*
* @param action Intent action.
* @param data Intent data, or <code>null</code>.
* @param extraData Data for {@link SearchManager#EXTRA_DATA_KEY} or <code>null</code>.
* @param query Intent query, or <code>null</code>.
* @param componentName Data for {@link SearchManager#COMPONENT_NAME_KEY} or <code>null</code>.
* @param actionKey The key code of the action key that was pressed,
* or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
* @param actionMsg The message for the action key that was pressed,
* or <code>null</code> if none.
* @param mode The search mode, one of the acceptable values for
* {@link SearchManager#SEARCH_MODE}, or {@code null}.
* @return The intent.
*/
private Intent createIntent(String action, Uri data, String extraData, String query,
String componentName, int actionKey, String actionMsg, String mode) {
// Now build the Intent
Intent intent = new Intent(action);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// We need CLEAR_TOP to avoid reusing an old task that has other activities
// on top of the one we want. We don't want to do this in in-app search though,
// as it can be destructive to the activity stack.
if (mGlobalSearchMode) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
}
if (data != null) {
intent.setData(data);
}
intent.putExtra(SearchManager.USER_QUERY, mUserQuery);
if (query != null) {
intent.putExtra(SearchManager.QUERY, query);
}
if (extraData != null) {
intent.putExtra(SearchManager.EXTRA_DATA_KEY, extraData);
}
if (componentName != null) {
intent.putExtra(SearchManager.COMPONENT_NAME_KEY, componentName);
}
if (mAppSearchData != null) {
intent.putExtra(SearchManager.APP_DATA, mAppSearchData);
}
if (actionKey != KeyEvent.KEYCODE_UNKNOWN) {
intent.putExtra(SearchManager.ACTION_KEY, actionKey);
intent.putExtra(SearchManager.ACTION_MSG, actionMsg);
}
if (mode != null) {
intent.putExtra(SearchManager.SEARCH_MODE, mode);
}
// Only allow 3rd-party intents from GlobalSearch
if (!mGlobalSearchMode) {
intent.setComponent(mSearchable.getSearchActivity());
}
return intent;
}
/**
* For a given suggestion and a given cursor row, get the action message. If not provided
* by the specific row/column, also check for a single definition (for the action key).
*
* @param c The cursor providing suggestions
* @param actionKey The actionkey record being examined
*
* @return Returns a string, or null if no action key message for this suggestion
*/
private static String getActionKeyMessage(Cursor c, SearchableInfo.ActionKeyInfo actionKey) {
String result = null;
// check first in the cursor data, for a suggestion-specific message
final String column = actionKey.getSuggestActionMsgColumn();
if (column != null) {
result = SuggestionsAdapter.getColumnString(c, column);
}
// If the cursor didn't give us a message, see if there's a single message defined
// for the actionkey (for all suggestions)
if (result == null) {
result = actionKey.getSuggestActionMsg();
}
return result;
}
/**
* The root element in the search bar layout. This is a custom view just to override
* the handling of the back button.
*/
public static class SearchBar extends LinearLayout {
private SearchDialog mSearchDialog;
public SearchBar(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SearchBar(Context context) {
super(context);
}
public void setSearchDialog(SearchDialog searchDialog) {
mSearchDialog = searchDialog;
}
/**
* Overrides the handling of the back key to move back to the previous sources or dismiss
* the search dialog, instead of dismissing the input method.
*/
@Override
public boolean dispatchKeyEventPreIme(KeyEvent event) {
if (DBG) Log.d(LOG_TAG, "onKeyPreIme(" + event + ")");
if (mSearchDialog != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
KeyEvent.DispatcherState state = getKeyDispatcherState();
if (state != null) {
if (event.getAction() == KeyEvent.ACTION_DOWN
&& event.getRepeatCount() == 0) {
state.startTracking(event, this);
return true;
} else if (event.getAction() == KeyEvent.ACTION_UP
&& !event.isCanceled() && state.isTracking(event)) {
mSearchDialog.onBackPressed();
return true;
}
}
}
return super.dispatchKeyEventPreIme(event);
}
}
/**
* Local subclass for AutoCompleteTextView.
*/
public static class SearchAutoComplete extends AutoCompleteTextView {
private int mThreshold;
public SearchAutoComplete(Context context) {
super(context);
mThreshold = getThreshold();
}
public SearchAutoComplete(Context context, AttributeSet attrs) {
super(context, attrs);
mThreshold = getThreshold();
}
public SearchAutoComplete(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mThreshold = getThreshold();
}
@Override
public void setThreshold(int threshold) {
super.setThreshold(threshold);
mThreshold = threshold;
}
/**
* Returns true if the text field is empty, or contains only whitespace.
*/
private boolean isEmpty() {
return TextUtils.getTrimmedLength(getText()) == 0;
}
/**
* We override this method to avoid replacing the query box text
* when a suggestion is clicked.
*/
@Override
protected void replaceText(CharSequence text) {
}
/**
* We override this method to avoid an extra onItemClick being called on the
* drop-down's OnItemClickListener by {@link AutoCompleteTextView#onKeyUp(int, KeyEvent)}
* when an item is clicked with the trackball.
*/
@Override
public void performCompletion() {
}
/**
* We override this method to be sure and show the soft keyboard if appropriate when
* the TextView has focus.
*/
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
if (hasWindowFocus) {
InputMethodManager inputManager = (InputMethodManager)
getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputManager.showSoftInput(this, 0);
}
}
/**
* We override this method so that we can allow a threshold of zero, which ACTV does not.
*/
@Override
public boolean enoughToFilter() {
return mThreshold <= 0 || super.enoughToFilter();
}
}
@Override
public void onBackPressed() {
// If the input method is covering the search dialog completely,
// e.g. in landscape mode with no hard keyboard, dismiss just the input method
InputMethodManager imm = (InputMethodManager)getContext()
.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null && imm.isFullscreenMode() &&
imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0)) {
return;
}
// Otherwise, go back to any previous source (e.g. back to QSB when
// pivoted into a source.
if (!backToPreviousComponent()) {
// If no previous source, close search dialog
cancel();
}
}
/**
* Implements OnItemClickListener
*/
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (DBG) Log.d(LOG_TAG, "onItemClick() position " + position);
launchSuggestion(position);
}
/**
* Implements OnItemSelectedListener
*/
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
if (DBG) Log.d(LOG_TAG, "onItemSelected() position " + position);
// A suggestion has been selected, rewrite the query if possible,
// otherwise the restore the original query.
if (REWRITE_QUERIES) {
rewriteQueryFromSuggestion(position);
}
}
/**
* Implements OnItemSelectedListener
*/
public void onNothingSelected(AdapterView<?> parent) {
if (DBG) Log.d(LOG_TAG, "onNothingSelected()");
}
/**
* Query rewriting.
*/
private void rewriteQueryFromSuggestion(int position) {
Cursor c = mSuggestionsAdapter.getCursor();
if (c == null) {
return;
}
if (c.moveToPosition(position)) {
// Get the new query from the suggestion.
CharSequence newQuery = mSuggestionsAdapter.convertToString(c);
if (newQuery != null) {
// The suggestion rewrites the query.
if (DBG) Log.d(LOG_TAG, "Rewriting query to '" + newQuery + "'");
// Update the text field, without getting new suggestions.
setQuery(newQuery);
} else {
// The suggestion does not rewrite the query, restore the user's query.
if (DBG) Log.d(LOG_TAG, "Suggestion gives no rewrite, restoring user query.");
restoreUserQuery();
}
} else {
// We got a bad position, restore the user's query.
Log.w(LOG_TAG, "Bad suggestion position: " + position);
restoreUserQuery();
}
}
/**
* Restores the query entered by the user if needed.
*/
private void restoreUserQuery() {
if (DBG) Log.d(LOG_TAG, "Restoring query to '" + mUserQuery + "'");
setQuery(mUserQuery);
}
/**
* Sets the text in the query box, without updating the suggestions.
*/
private void setQuery(CharSequence query) {
mSearchAutoComplete.setText(query, false);
if (query != null) {
mSearchAutoComplete.setSelection(query.length());
}
}
/**
* Sets the text in the query box, updating the suggestions.
*/
private void setUserQuery(String query) {
if (query == null) {
query = "";
}
mUserQuery = query;
mSourceSelector.setQuery(query);
mSearchAutoComplete.setText(query);
mSearchAutoComplete.setSelection(query.length());
}
/**
* Debugging Support
*/
/**
* For debugging only, sample the millisecond clock and log it.
* Uses AtomicLong so we can use in multiple threads
*/
private AtomicLong mLastLogTime = new AtomicLong(SystemClock.uptimeMillis());
private void dbgLogTiming(final String caller) {
long millis = SystemClock.uptimeMillis();
long oldTime = mLastLogTime.getAndSet(millis);
long delta = millis - oldTime;
final String report = millis + " (+" + delta + ") ticks for Search keystroke in " + caller;
Log.d(LOG_TAG,report);
}
}