/* * 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.server.search.SearchableInfo; import android.speech.RecognizerIntent; import android.text.Editable; import android.text.InputType; import android.text.TextUtils; import android.text.TextWatcher; import android.text.util.Regex; 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.ImageView; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemSelectedListener; import java.util.ArrayList; import java.util.WeakHashMap; import java.util.concurrent.atomic.AtomicLong; /** * System search dialog. This is controlled by the * SearchManagerService and runs in the system 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 ImageView mAppIcon; 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; // 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) { 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); } /** * 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.type = WindowManager.LayoutParams.TYPE_SEARCH_BAR; lp.width = ViewGroup.LayoutParams.FILL_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.FILL_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); mAppIcon = (ImageView) findViewById(com.android.internal.R.id.search_app_icon); 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 = ""; return doShow(currentSearchText, false, null, mAppSearchData, 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() { // 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()) { mAppIcon.setImageResource(0); mAppIcon.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"); } mAppIcon.setImageDrawable(icon); mAppIcon.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(); } } 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 (Regex.WEB_URL_PATTERN.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. Doesn't dismiss the dialog * since that will be handled in {@link SearchDialogWrapper#performActivityResuming} */ 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; 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); } }