SearchDialog.java revision 444c727e0eecf83e9d0b9c4e7af5cbf5fc4135f8
1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.app;
18
19import static android.app.SuggestionsAdapter.getColumnString;
20
21import android.content.ActivityNotFoundException;
22import android.content.ComponentName;
23import android.content.ContentResolver;
24import android.content.ContentValues;
25import android.content.Context;
26import android.content.Intent;
27import android.content.pm.ActivityInfo;
28import android.content.pm.PackageManager;
29import android.content.pm.ResolveInfo;
30import android.content.pm.PackageManager.NameNotFoundException;
31import android.content.res.Configuration;
32import android.content.res.Resources;
33import android.database.Cursor;
34import android.graphics.drawable.Animatable;
35import android.graphics.drawable.Drawable;
36import android.net.Uri;
37import android.os.Bundle;
38import android.os.SystemClock;
39import android.server.search.SearchableInfo;
40import android.speech.RecognizerIntent;
41import android.text.Editable;
42import android.text.InputType;
43import android.text.TextUtils;
44import android.text.TextWatcher;
45import android.text.util.Regex;
46import android.util.AttributeSet;
47import android.util.Log;
48import android.view.ContextThemeWrapper;
49import android.view.Gravity;
50import android.view.KeyEvent;
51import android.view.MotionEvent;
52import android.view.View;
53import android.view.ViewConfiguration;
54import android.view.ViewGroup;
55import android.view.Window;
56import android.view.WindowManager;
57import android.view.inputmethod.EditorInfo;
58import android.view.inputmethod.InputMethodManager;
59import android.widget.AdapterView;
60import android.widget.AutoCompleteTextView;
61import android.widget.Button;
62import android.widget.ImageButton;
63import android.widget.ImageView;
64import android.widget.ListView;
65import android.widget.TextView;
66import android.widget.AdapterView.OnItemClickListener;
67import android.widget.AdapterView.OnItemSelectedListener;
68
69import java.util.ArrayList;
70import java.util.WeakHashMap;
71import java.util.concurrent.atomic.AtomicLong;
72
73/**
74 * System search dialog. This is controlled by the
75 * SearchManagerService and runs in the system process.
76 *
77 * @hide
78 */
79public class SearchDialog extends Dialog implements OnItemClickListener, OnItemSelectedListener {
80
81    // Debugging support
82    private static final boolean DBG = false;
83    private static final String LOG_TAG = "SearchDialog";
84    private static final boolean DBG_LOG_TIMING = false;
85
86    private static final String INSTANCE_KEY_COMPONENT = "comp";
87    private static final String INSTANCE_KEY_APPDATA = "data";
88    private static final String INSTANCE_KEY_GLOBALSEARCH = "glob";
89    private static final String INSTANCE_KEY_STORED_COMPONENT = "sComp";
90    private static final String INSTANCE_KEY_STORED_APPDATA = "sData";
91    private static final String INSTANCE_KEY_PREVIOUS_COMPONENTS = "sPrev";
92    private static final String INSTANCE_KEY_USER_QUERY = "uQry";
93
94    private static final int SEARCH_PLATE_LEFT_PADDING_GLOBAL = 12;
95    private static final int SEARCH_PLATE_LEFT_PADDING_NON_GLOBAL = 7;
96
97    // views & widgets
98    private TextView mBadgeLabel;
99    private ImageView mAppIcon;
100    private SearchAutoComplete mSearchAutoComplete;
101    private Button mGoButton;
102    private ImageButton mVoiceButton;
103    private View mSearchPlate;
104    private Drawable mWorkingSpinner;
105
106    // interaction with searchable application
107    private SearchableInfo mSearchable;
108    private ComponentName mLaunchComponent;
109    private Bundle mAppSearchData;
110    private boolean mGlobalSearchMode;
111    private Context mActivityContext;
112
113    // Values we store to allow user to toggle between in-app search and global search.
114    private ComponentName mStoredComponentName;
115    private Bundle mStoredAppSearchData;
116
117    // stack of previous searchables, to support the BACK key after
118    // SearchManager.INTENT_ACTION_CHANGE_SEARCH_SOURCE.
119    // The top of the stack (= previous searchable) is the last element of the list,
120    // since adding and removing is efficient at the end of an ArrayList.
121    private ArrayList<ComponentName> mPreviousComponents;
122
123    // For voice searching
124    private Intent mVoiceWebSearchIntent;
125    private Intent mVoiceAppSearchIntent;
126
127    // support for AutoCompleteTextView suggestions display
128    private SuggestionsAdapter mSuggestionsAdapter;
129
130    // Whether to rewrite queries when selecting suggestions
131    private static final boolean REWRITE_QUERIES = true;
132
133    // The query entered by the user. This is not changed when selecting a suggestion
134    // that modifies the contents of the text field. But if the user then edits
135    // the suggestion, the resulting string is saved.
136    private String mUserQuery;
137
138    // A weak map of drawables we've gotten from other packages, so we don't load them
139    // more than once.
140    private final WeakHashMap<String, Drawable> mOutsideDrawablesCache =
141            new WeakHashMap<String, Drawable>();
142
143    // Last known IME options value for the search edit text.
144    private int mSearchAutoCompleteImeOptions;
145
146    /**
147     * Constructor - fires it up and makes it look like the search UI.
148     *
149     * @param context Application Context we can use for system acess
150     */
151    public SearchDialog(Context context) {
152        super(context, com.android.internal.R.style.Theme_GlobalSearchBar);
153    }
154
155    /**
156     * We create the search dialog just once, and it stays around (hidden)
157     * until activated by the user.
158     */
159    @Override
160    protected void onCreate(Bundle savedInstanceState) {
161        super.onCreate(savedInstanceState);
162
163        setContentView(com.android.internal.R.layout.search_bar);
164
165        Window theWindow = getWindow();
166        WindowManager.LayoutParams lp = theWindow.getAttributes();
167        lp.type = WindowManager.LayoutParams.TYPE_SEARCH_BAR;
168        lp.width = ViewGroup.LayoutParams.FILL_PARENT;
169        // taking up the whole window (even when transparent) is less than ideal,
170        // but necessary to show the popup window until the window manager supports
171        // having windows anchored by their parent but not clipped by them.
172        lp.height = ViewGroup.LayoutParams.FILL_PARENT;
173        lp.gravity = Gravity.TOP | Gravity.FILL_HORIZONTAL;
174        lp.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
175        theWindow.setAttributes(lp);
176
177        // get the view elements for local access
178        mBadgeLabel = (TextView) findViewById(com.android.internal.R.id.search_badge);
179        mSearchAutoComplete = (SearchAutoComplete)
180                findViewById(com.android.internal.R.id.search_src_text);
181        mAppIcon = (ImageView) findViewById(com.android.internal.R.id.search_app_icon);
182        mGoButton = (Button) findViewById(com.android.internal.R.id.search_go_btn);
183        mVoiceButton = (ImageButton) findViewById(com.android.internal.R.id.search_voice_btn);
184        mSearchPlate = findViewById(com.android.internal.R.id.search_plate);
185        mWorkingSpinner = getContext().getResources().
186                getDrawable(com.android.internal.R.drawable.search_spinner);
187
188        // attach listeners
189        mSearchAutoComplete.addTextChangedListener(mTextWatcher);
190        mSearchAutoComplete.setOnKeyListener(mTextKeyListener);
191        mSearchAutoComplete.setOnItemClickListener(this);
192        mSearchAutoComplete.setOnItemSelectedListener(this);
193        mGoButton.setOnClickListener(mGoButtonClickListener);
194        mGoButton.setOnKeyListener(mButtonsKeyListener);
195        mVoiceButton.setOnClickListener(mVoiceButtonClickListener);
196        mVoiceButton.setOnKeyListener(mButtonsKeyListener);
197
198        mSearchAutoComplete.setSearchDialog(this);
199
200        // pre-hide all the extraneous elements
201        mBadgeLabel.setVisibility(View.GONE);
202
203        // Additional adjustments to make Dialog work for Search
204
205        // Touching outside of the search dialog will dismiss it
206        setCanceledOnTouchOutside(true);
207
208        // Save voice intent for later queries/launching
209        mVoiceWebSearchIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH);
210        mVoiceWebSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
211        mVoiceWebSearchIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
212                RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH);
213
214        mVoiceAppSearchIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
215        mVoiceAppSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
216
217        mSearchAutoCompleteImeOptions = mSearchAutoComplete.getImeOptions();
218    }
219
220    /**
221     * Set up the search dialog
222     *
223     * @return true if search dialog launched, false if not
224     */
225    public boolean show(String initialQuery, boolean selectInitialQuery,
226            ComponentName componentName, Bundle appSearchData, boolean globalSearch) {
227
228        // Reset any stored values from last time dialog was shown.
229        mStoredComponentName = null;
230        mStoredAppSearchData = null;
231
232        boolean success = doShow(initialQuery, selectInitialQuery, componentName, appSearchData,
233                globalSearch);
234        if (success) {
235            // Display the drop down as soon as possible instead of waiting for the rest of the
236            // pending UI stuff to get done, so that things appear faster to the user.
237            mSearchAutoComplete.showDropDownAfterLayout();
238        }
239        return success;
240    }
241
242    /**
243     * Called in response to a press of the hard search button in
244     * {@link #onKeyDown(int, KeyEvent)}, this method toggles between in-app
245     * search and global search when relevant.
246     *
247     * If pressed within an in-app search context, this switches the search dialog out to
248     * global search. If pressed within a global search context that was originally an in-app
249     * search context, this switches back to the in-app search context. If pressed within a
250     * global search context that has no original in-app search context (e.g., global search
251     * from Home), this does nothing.
252     *
253     * @return false if we wanted to toggle context but could not do so successfully, true
254     * in all other cases
255     */
256    private boolean toggleGlobalSearch() {
257        String currentSearchText = mSearchAutoComplete.getText().toString();
258        if (!mGlobalSearchMode) {
259            mStoredComponentName = mLaunchComponent;
260            mStoredAppSearchData = mAppSearchData;
261            return doShow(currentSearchText, false, null, mAppSearchData, true);
262        } else {
263            if (mStoredComponentName != null) {
264                // This means we should toggle *back* to an in-app search context from
265                // global search.
266                return doShow(currentSearchText, false, mStoredComponentName,
267                        mStoredAppSearchData, false);
268            } else {
269                return true;
270            }
271        }
272    }
273
274    /**
275     * Does the rest of the work required to show the search dialog. Called by both
276     * {@link #show(String, boolean, ComponentName, Bundle, boolean)} and
277     * {@link #toggleGlobalSearch()}.
278     *
279     * @return true if search dialog showed, false if not
280     */
281    private boolean doShow(String initialQuery, boolean selectInitialQuery,
282            ComponentName componentName, Bundle appSearchData,
283            boolean globalSearch) {
284        // set up the searchable and show the dialog
285        if (!show(componentName, appSearchData, globalSearch)) {
286            return false;
287        }
288
289        // finally, load the user's initial text (which may trigger suggestions)
290        setUserQuery(initialQuery);
291        if (selectInitialQuery) {
292            mSearchAutoComplete.selectAll();
293        }
294
295        return true;
296    }
297
298    /**
299     * Sets up the search dialog and shows it.
300     *
301     * @return <code>true</code> if search dialog launched
302     */
303    private boolean show(ComponentName componentName, Bundle appSearchData,
304            boolean globalSearch) {
305
306        if (DBG) {
307            Log.d(LOG_TAG, "show(" + componentName + ", "
308                    + appSearchData + ", " + globalSearch + ")");
309        }
310
311        SearchManager searchManager = (SearchManager)
312                mContext.getSystemService(Context.SEARCH_SERVICE);
313        // Try to get the searchable info for the provided component (or for global search,
314        // if globalSearch == true).
315        mSearchable = searchManager.getSearchableInfo(componentName, globalSearch);
316
317        // If we got back nothing, and it wasn't a request for global search, then try again
318        // for global search, as we'll try to launch that in lieu of any component-specific search.
319        if (!globalSearch && mSearchable == null) {
320            globalSearch = true;
321            mSearchable = searchManager.getSearchableInfo(componentName, globalSearch);
322
323            // If we still get back null (i.e., there's not even a searchable info available
324            // for global search), then really give up.
325            if (mSearchable == null) {
326                // Unfortunately, we can't log here.  it would be logspam every time the user
327                // clicks the "search" key on a non-search app.
328                return false;
329            }
330        }
331
332        mLaunchComponent = componentName;
333        mAppSearchData = appSearchData;
334        // Using globalSearch here is just an optimization, just calling
335        // isDefaultSearchable() should always give the same result.
336        mGlobalSearchMode = globalSearch || searchManager.isDefaultSearchable(mSearchable);
337        mActivityContext = mSearchable.getActivityContext(getContext());
338
339        // show the dialog. this will call onStart().
340        if (!isShowing()) {
341            // First make sure the keyboard is showing (if needed), so that we get the right height
342            // for the dropdown to respect the IME.
343            if (getContext().getResources().getConfiguration().hardKeyboardHidden ==
344                Configuration.HARDKEYBOARDHIDDEN_YES) {
345                InputMethodManager inputManager = (InputMethodManager)
346                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
347                        inputManager.showSoftInputUnchecked(0, null);
348            }
349
350            // The Dialog uses a ContextThemeWrapper for the context; use this to change the
351            // theme out from underneath us, between the global search theme and the in-app
352            // search theme. They are identical except that the global search theme does not
353            // dim the background of the window (because global search is full screen so it's
354            // not needed and this should save a little bit of time on global search invocation).
355            Object context = getContext();
356            if (context instanceof ContextThemeWrapper) {
357                ContextThemeWrapper wrapper = (ContextThemeWrapper) context;
358                if (globalSearch) {
359                    wrapper.setTheme(com.android.internal.R.style.Theme_GlobalSearchBar);
360                } else {
361                    wrapper.setTheme(com.android.internal.R.style.Theme_SearchBar);
362                }
363            }
364            show();
365        }
366
367        updateUI();
368
369        return true;
370    }
371
372    /**
373     * The search dialog is being dismissed, so handle all of the local shutdown operations.
374     *
375     * This function is designed to be idempotent so that dismiss() can be safely called at any time
376     * (even if already closed) and more likely to really dump any memory.  No leaks!
377     */
378    @Override
379    public void onStop() {
380        super.onStop();
381
382        closeSuggestionsAdapter();
383
384        // dump extra memory we're hanging on to
385        mLaunchComponent = null;
386        mAppSearchData = null;
387        mSearchable = null;
388        mActivityContext = null;
389        mUserQuery = null;
390        mPreviousComponents = null;
391    }
392
393    /**
394     * Sets the search dialog to the 'working' state, which shows a working spinner in the
395     * right hand size of the text field.
396     *
397     * @param working true to show spinner, false to hide spinner
398     */
399    public void setWorking(boolean working) {
400        if (working) {
401            mSearchAutoComplete.setCompoundDrawablesWithIntrinsicBounds(
402                    null, null, mWorkingSpinner, null);
403            ((Animatable) mWorkingSpinner).start();
404        } else {
405            mSearchAutoComplete.setCompoundDrawablesWithIntrinsicBounds(
406                    null, null, null, null);
407            ((Animatable) mWorkingSpinner).stop();
408        }
409    }
410
411    /**
412     * Closes and gets rid of the suggestions adapter.
413     */
414    private void closeSuggestionsAdapter() {
415        // remove the adapter from the autocomplete first, to avoid any updates
416        // when we drop the cursor
417        mSearchAutoComplete.setAdapter((SuggestionsAdapter)null);
418        // close any leftover cursor
419        if (mSuggestionsAdapter != null) {
420            mSuggestionsAdapter.changeCursor(null);
421        }
422        mSuggestionsAdapter = null;
423    }
424
425    /**
426     * Save the minimal set of data necessary to recreate the search
427     *
428     * @return A bundle with the state of the dialog, or {@code null} if the search
429     *         dialog is not showing.
430     */
431    @Override
432    public Bundle onSaveInstanceState() {
433        if (!isShowing()) return null;
434
435        Bundle bundle = new Bundle();
436
437        // setup info so I can recreate this particular search
438        bundle.putParcelable(INSTANCE_KEY_COMPONENT, mLaunchComponent);
439        bundle.putBundle(INSTANCE_KEY_APPDATA, mAppSearchData);
440        bundle.putBoolean(INSTANCE_KEY_GLOBALSEARCH, mGlobalSearchMode);
441        bundle.putParcelable(INSTANCE_KEY_STORED_COMPONENT, mStoredComponentName);
442        bundle.putBundle(INSTANCE_KEY_STORED_APPDATA, mStoredAppSearchData);
443        bundle.putParcelableArrayList(INSTANCE_KEY_PREVIOUS_COMPONENTS, mPreviousComponents);
444        bundle.putString(INSTANCE_KEY_USER_QUERY, mUserQuery);
445
446        return bundle;
447    }
448
449    /**
450     * Restore the state of the dialog from a previously saved bundle.
451     *
452     * TODO: go through this and make sure that it saves everything that is saved
453     *
454     * @param savedInstanceState The state of the dialog previously saved by
455     *     {@link #onSaveInstanceState()}.
456     */
457    @Override
458    public void onRestoreInstanceState(Bundle savedInstanceState) {
459        if (savedInstanceState == null) return;
460
461        ComponentName launchComponent = savedInstanceState.getParcelable(INSTANCE_KEY_COMPONENT);
462        Bundle appSearchData = savedInstanceState.getBundle(INSTANCE_KEY_APPDATA);
463        boolean globalSearch = savedInstanceState.getBoolean(INSTANCE_KEY_GLOBALSEARCH);
464        ComponentName storedComponentName =
465                savedInstanceState.getParcelable(INSTANCE_KEY_STORED_COMPONENT);
466        Bundle storedAppSearchData =
467                savedInstanceState.getBundle(INSTANCE_KEY_STORED_APPDATA);
468        ArrayList<ComponentName> previousComponents =
469                savedInstanceState.getParcelableArrayList(INSTANCE_KEY_PREVIOUS_COMPONENTS);
470        String userQuery = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY);
471
472        // Set stored state
473        mStoredComponentName = storedComponentName;
474        mStoredAppSearchData = storedAppSearchData;
475        mPreviousComponents = previousComponents;
476
477        // show the dialog.
478        if (!doShow(userQuery, false, launchComponent, appSearchData, globalSearch)) {
479            // for some reason, we couldn't re-instantiate
480            return;
481        }
482    }
483
484    /**
485     * Called after resources have changed, e.g. after screen rotation or locale change.
486     */
487    public void onConfigurationChanged() {
488        if (isShowing()) {
489            // Redraw (resources may have changed)
490            updateSearchButton();
491            updateSearchAppIcon();
492            updateSearchBadge();
493            updateQueryHint();
494        }
495    }
496
497    /**
498     * Update the UI according to the info in the current value of {@link #mSearchable}.
499     */
500    private void updateUI() {
501        if (mSearchable != null) {
502            updateSearchAutoComplete();
503            updateSearchButton();
504            updateSearchAppIcon();
505            updateSearchBadge();
506            updateQueryHint();
507            updateVoiceButton();
508
509            // In order to properly configure the input method (if one is being used), we
510            // need to let it know if we'll be providing suggestions.  Although it would be
511            // difficult/expensive to know if every last detail has been configured properly, we
512            // can at least see if a suggestions provider has been configured, and use that
513            // as our trigger.
514            int inputType = mSearchable.getInputType();
515            // We only touch this if the input type is set up for text (which it almost certainly
516            // should be, in the case of search!)
517            if ((inputType & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT) {
518                // The existence of a suggestions authority is the proxy for "suggestions
519                // are available here"
520                inputType &= ~InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE;
521                if (mSearchable.getSuggestAuthority() != null) {
522                    inputType |= InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE;
523                }
524            }
525            mSearchAutoComplete.setInputType(inputType);
526            mSearchAutoCompleteImeOptions = mSearchable.getImeOptions();
527            mSearchAutoComplete.setImeOptions(mSearchAutoCompleteImeOptions);
528        }
529    }
530
531    /**
532     * Updates the auto-complete text view.
533     */
534    private void updateSearchAutoComplete() {
535        // close any existing suggestions adapter
536        closeSuggestionsAdapter();
537
538        mSearchAutoComplete.setDropDownAnimationStyle(0); // no animation
539        mSearchAutoComplete.setThreshold(mSearchable.getSuggestThreshold());
540        // we dismiss the entire dialog instead
541        mSearchAutoComplete.setDropDownDismissedOnCompletion(false);
542
543        if (mGlobalSearchMode) {
544            mSearchAutoComplete.setDropDownAlwaysVisible(true);  // fill space until results come in
545        } else {
546            mSearchAutoComplete.setDropDownAlwaysVisible(false);
547        }
548
549        // attach the suggestions adapter, if suggestions are available
550        // The existence of a suggestions authority is the proxy for "suggestions available here"
551        if (mSearchable.getSuggestAuthority() != null) {
552            mSuggestionsAdapter = new SuggestionsAdapter(getContext(), this, mSearchable,
553                    mOutsideDrawablesCache, mGlobalSearchMode);
554            mSearchAutoComplete.setAdapter(mSuggestionsAdapter);
555        }
556    }
557
558    /**
559     * Update the text in the search button.  Note: This is deprecated functionality, for
560     * 1.0 compatibility only.
561     */
562    private void updateSearchButton() {
563        String textLabel = null;
564        Drawable iconLabel = null;
565        int textId = mSearchable.getSearchButtonText();
566        if (textId != 0) {
567            textLabel = mActivityContext.getResources().getString(textId);
568        } else {
569            iconLabel = getContext().getResources().
570                    getDrawable(com.android.internal.R.drawable.ic_btn_search);
571        }
572        mGoButton.setText(textLabel);
573        mGoButton.setCompoundDrawablesWithIntrinsicBounds(iconLabel, null, null, null);
574    }
575
576    private void updateSearchAppIcon() {
577        if (mGlobalSearchMode) {
578            mAppIcon.setImageResource(0);
579            mAppIcon.setVisibility(View.GONE);
580            mSearchPlate.setPadding(SEARCH_PLATE_LEFT_PADDING_GLOBAL,
581                    mSearchPlate.getPaddingTop(),
582                    mSearchPlate.getPaddingRight(),
583                    mSearchPlate.getPaddingBottom());
584        } else {
585            PackageManager pm = getContext().getPackageManager();
586            Drawable icon;
587            try {
588                ActivityInfo info = pm.getActivityInfo(mLaunchComponent, 0);
589                icon = pm.getApplicationIcon(info.applicationInfo);
590                if (DBG) Log.d(LOG_TAG, "Using app-specific icon");
591            } catch (NameNotFoundException e) {
592                icon = pm.getDefaultActivityIcon();
593                Log.w(LOG_TAG, mLaunchComponent + " not found, using generic app icon");
594            }
595            mAppIcon.setImageDrawable(icon);
596            mAppIcon.setVisibility(View.VISIBLE);
597            mSearchPlate.setPadding(SEARCH_PLATE_LEFT_PADDING_NON_GLOBAL,
598                    mSearchPlate.getPaddingTop(),
599                    mSearchPlate.getPaddingRight(),
600                    mSearchPlate.getPaddingBottom());
601        }
602    }
603
604    /**
605     * Setup the search "Badge" if requested by mode flags.
606     */
607    private void updateSearchBadge() {
608        // assume both hidden
609        int visibility = View.GONE;
610        Drawable icon = null;
611        CharSequence text = null;
612
613        // optionally show one or the other.
614        if (mSearchable.useBadgeIcon()) {
615            icon = mActivityContext.getResources().getDrawable(mSearchable.getIconId());
616            visibility = View.VISIBLE;
617            if (DBG) Log.d(LOG_TAG, "Using badge icon: " + mSearchable.getIconId());
618        } else if (mSearchable.useBadgeLabel()) {
619            text = mActivityContext.getResources().getText(mSearchable.getLabelId()).toString();
620            visibility = View.VISIBLE;
621            if (DBG) Log.d(LOG_TAG, "Using badge label: " + mSearchable.getLabelId());
622        }
623
624        mBadgeLabel.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
625        mBadgeLabel.setText(text);
626        mBadgeLabel.setVisibility(visibility);
627    }
628
629    /**
630     * Update the hint in the query text field.
631     */
632    private void updateQueryHint() {
633        if (isShowing()) {
634            String hint = null;
635            if (mSearchable != null) {
636                int hintId = mSearchable.getHintId();
637                if (hintId != 0) {
638                    hint = mActivityContext.getString(hintId);
639                }
640            }
641            mSearchAutoComplete.setHint(hint);
642        }
643    }
644
645    /**
646     * Update the visibility of the voice button.  There are actually two voice search modes,
647     * either of which will activate the button.
648     */
649    private void updateVoiceButton() {
650        int visibility = View.GONE;
651        if (mSearchable.getVoiceSearchEnabled()) {
652            Intent testIntent = null;
653            if (mSearchable.getVoiceSearchLaunchWebSearch()) {
654                testIntent = mVoiceWebSearchIntent;
655            } else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
656                testIntent = mVoiceAppSearchIntent;
657            }
658            if (testIntent != null) {
659                ResolveInfo ri = getContext().getPackageManager().
660                        resolveActivity(testIntent, PackageManager.MATCH_DEFAULT_ONLY);
661                if (ri != null) {
662                    visibility = View.VISIBLE;
663                }
664            }
665        }
666        mVoiceButton.setVisibility(visibility);
667    }
668
669    /**
670     * Listeners of various types
671     */
672
673    /**
674     * {@link Dialog#onTouchEvent(MotionEvent)} will cancel the dialog only when the
675     * touch is outside the window. But the window includes space for the drop-down,
676     * so we also cancel on taps outside the search bar when the drop-down is not showing.
677     */
678    @Override
679    public boolean onTouchEvent(MotionEvent event) {
680        // cancel if the drop-down is not showing and the touch event was outside the search plate
681        if (!mSearchAutoComplete.isPopupShowing() && isOutOfBounds(mSearchPlate, event)) {
682            if (DBG) Log.d(LOG_TAG, "Pop-up not showing and outside of search plate.");
683            cancel();
684            return true;
685        }
686        // Let Dialog handle events outside the window while the pop-up is showing.
687        return super.onTouchEvent(event);
688    }
689
690    private boolean isOutOfBounds(View v, MotionEvent event) {
691        final int x = (int) event.getX();
692        final int y = (int) event.getY();
693        final int slop = ViewConfiguration.get(mContext).getScaledWindowTouchSlop();
694        return (x < -slop) || (y < -slop)
695                || (x > (v.getWidth()+slop))
696                || (y > (v.getHeight()+slop));
697    }
698
699    /**
700     * Dialog's OnKeyListener implements various search-specific functionality
701     *
702     * @param keyCode This is the keycode of the typed key, and is the same value as
703     *        found in the KeyEvent parameter.
704     * @param event The complete event record for the typed key
705     *
706     * @return Return true if the event was handled here, or false if not.
707     */
708    @Override
709    public boolean onKeyDown(int keyCode, KeyEvent event) {
710        if (DBG) Log.d(LOG_TAG, "onKeyDown(" + keyCode + "," + event + ")");
711
712        // handle back key to go back to previous searchable, etc.
713        if (handleBackKey(keyCode, event)) {
714            return true;
715        }
716
717        if (keyCode == KeyEvent.KEYCODE_SEARCH) {
718            // If the search key is pressed, toggle between global and in-app search. If we are
719            // currently doing global search and there is no in-app search context to toggle to,
720            // just don't do anything.
721            return toggleGlobalSearch();
722        }
723
724        // if it's an action specified by the searchable activity, launch the
725        // entered query with the action key
726        SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
727        if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) {
728            launchQuerySearch(keyCode, actionKey.getQueryActionMsg());
729            return true;
730        }
731
732        return false;
733    }
734
735    /**
736     * Callback to watch the textedit field for empty/non-empty
737     */
738    private TextWatcher mTextWatcher = new TextWatcher() {
739
740        public void beforeTextChanged(CharSequence s, int start, int before, int after) { }
741
742        public void onTextChanged(CharSequence s, int start,
743                int before, int after) {
744            if (DBG_LOG_TIMING) {
745                dbgLogTiming("onTextChanged()");
746            }
747            updateWidgetState();
748            if (!mSearchAutoComplete.isPerformingCompletion()) {
749                // The user changed the query, remember it.
750                mUserQuery = s == null ? "" : s.toString();
751            }
752        }
753
754        public void afterTextChanged(Editable s) {
755            if (mSearchable.autoUrlDetect() && !mSearchAutoComplete.isPerformingCompletion()) {
756                // The user changed the query, check if it is a URL and if so change the search
757                // button in the soft keyboard to the 'Go' button.
758                int options = (mSearchAutoComplete.getImeOptions() & (~EditorInfo.IME_MASK_ACTION));
759                if (Regex.WEB_URL_PATTERN.matcher(mUserQuery).matches()) {
760                    options = options | EditorInfo.IME_ACTION_GO;
761                } else {
762                    options = options | EditorInfo.IME_ACTION_SEARCH;
763                }
764                if (options != mSearchAutoCompleteImeOptions) {
765                    mSearchAutoCompleteImeOptions = options;
766                    mSearchAutoComplete.setImeOptions(options);
767                    // This call is required to update the soft keyboard UI with latest IME flags.
768                    mSearchAutoComplete.setInputType(mSearchAutoComplete.getInputType());
769                }
770            }
771        }
772    };
773
774    /**
775     * Enable/Disable the cancel button based on edit text state (any text?)
776     */
777    private void updateWidgetState() {
778        // enable the button if we have one or more non-space characters
779        boolean enabled = !mSearchAutoComplete.isEmpty();
780        mGoButton.setEnabled(enabled);
781        mGoButton.setFocusable(enabled);
782    }
783
784    /**
785     * React to typing in the GO search button by refocusing to EditText.
786     * Continue typing the query.
787     */
788    View.OnKeyListener mButtonsKeyListener = new View.OnKeyListener() {
789        public boolean onKey(View v, int keyCode, KeyEvent event) {
790            // guard against possible race conditions
791            if (mSearchable == null) {
792                return false;
793            }
794
795            if (!event.isSystem() &&
796                    (keyCode != KeyEvent.KEYCODE_DPAD_UP) &&
797                    (keyCode != KeyEvent.KEYCODE_DPAD_DOWN) &&
798                    (keyCode != KeyEvent.KEYCODE_DPAD_LEFT) &&
799                    (keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) &&
800                    (keyCode != KeyEvent.KEYCODE_DPAD_CENTER)) {
801                // restore focus and give key to EditText ...
802                if (mSearchAutoComplete.requestFocus()) {
803                    return mSearchAutoComplete.dispatchKeyEvent(event);
804                }
805            }
806
807            return false;
808        }
809    };
810
811    /**
812     * React to a click in the GO button by launching a search.
813     */
814    View.OnClickListener mGoButtonClickListener = new View.OnClickListener() {
815        public void onClick(View v) {
816            // guard against possible race conditions
817            if (mSearchable == null) {
818                return;
819            }
820            launchQuerySearch();
821        }
822    };
823
824    /**
825     * React to a click in the voice search button.
826     */
827    View.OnClickListener mVoiceButtonClickListener = new View.OnClickListener() {
828        public void onClick(View v) {
829            // guard against possible race conditions
830            if (mSearchable == null) {
831                return;
832            }
833            try {
834                if (mSearchable.getVoiceSearchLaunchWebSearch()) {
835                    getContext().startActivity(mVoiceWebSearchIntent);
836                } else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
837                    Intent appSearchIntent = createVoiceAppSearchIntent(mVoiceAppSearchIntent);
838                    getContext().startActivity(appSearchIntent);
839                }
840            } catch (ActivityNotFoundException e) {
841                // Should not happen, since we check the availability of
842                // voice search before showing the button. But just in case...
843                Log.w(LOG_TAG, "Could not find voice search activity");
844            }
845         }
846    };
847
848    /**
849     * Create and return an Intent that can launch the voice search activity, perform a specific
850     * voice transcription, and forward the results to the searchable activity.
851     *
852     * @param baseIntent The voice app search intent to start from
853     * @return A completely-configured intent ready to send to the voice search activity
854     */
855    private Intent createVoiceAppSearchIntent(Intent baseIntent) {
856        // create the necessary intent to set up a search-and-forward operation
857        // in the voice search system.   We have to keep the bundle separate,
858        // because it becomes immutable once it enters the PendingIntent
859        Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
860        queryIntent.setComponent(mSearchable.getSearchActivity());
861        PendingIntent pending = PendingIntent.getActivity(
862                getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT);
863
864        // Now set up the bundle that will be inserted into the pending intent
865        // when it's time to do the search.  We always build it here (even if empty)
866        // because the voice search activity will always need to insert "QUERY" into
867        // it anyway.
868        Bundle queryExtras = new Bundle();
869        if (mAppSearchData != null) {
870            queryExtras.putBundle(SearchManager.APP_DATA, mAppSearchData);
871        }
872
873        // Now build the intent to launch the voice search.  Add all necessary
874        // extras to launch the voice recognizer, and then all the necessary extras
875        // to forward the results to the searchable activity
876        Intent voiceIntent = new Intent(baseIntent);
877
878        // Add all of the configuration options supplied by the searchable's metadata
879        String languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
880        String prompt = null;
881        String language = null;
882        int maxResults = 1;
883        Resources resources = mActivityContext.getResources();
884        if (mSearchable.getVoiceLanguageModeId() != 0) {
885            languageModel = resources.getString(mSearchable.getVoiceLanguageModeId());
886        }
887        if (mSearchable.getVoicePromptTextId() != 0) {
888            prompt = resources.getString(mSearchable.getVoicePromptTextId());
889        }
890        if (mSearchable.getVoiceLanguageId() != 0) {
891            language = resources.getString(mSearchable.getVoiceLanguageId());
892        }
893        if (mSearchable.getVoiceMaxResults() != 0) {
894            maxResults = mSearchable.getVoiceMaxResults();
895        }
896        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
897        voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
898        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
899        voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
900
901        // Add the values that configure forwarding the results
902        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
903        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
904
905        return voiceIntent;
906    }
907
908    /**
909     * Corrects http/https typo errors in the given url string, and if the protocol specifier was
910     * not present defaults to http.
911     *
912     * @param inUrl URL to check and fix
913     * @return fixed URL string.
914     */
915    private String fixUrl(String inUrl) {
916        if (inUrl.startsWith("http://") || inUrl.startsWith("https://"))
917            return inUrl;
918
919        if (inUrl.startsWith("http:") || inUrl.startsWith("https:")) {
920            if (inUrl.startsWith("http:/") || inUrl.startsWith("https:/")) {
921                inUrl = inUrl.replaceFirst("/", "//");
922            } else {
923                inUrl = inUrl.replaceFirst(":", "://");
924            }
925        }
926
927        if (inUrl.indexOf("://") == -1) {
928            inUrl = "http://" + inUrl;
929        }
930
931        return inUrl;
932    }
933
934    /**
935     * React to the user typing "enter" or other hardwired keys while typing in the search box.
936     * This handles these special keys while the edit box has focus.
937     */
938    View.OnKeyListener mTextKeyListener = new View.OnKeyListener() {
939        public boolean onKey(View v, int keyCode, KeyEvent event) {
940            // guard against possible race conditions
941            if (mSearchable == null) {
942                return false;
943            }
944
945            if (DBG_LOG_TIMING) dbgLogTiming("doTextKey()");
946            if (DBG) {
947                Log.d(LOG_TAG, "mTextListener.onKey(" + keyCode + "," + event
948                        + "), selection: " + mSearchAutoComplete.getListSelection());
949            }
950
951            // If a suggestion is selected, handle enter, search key, and action keys
952            // as presses on the selected suggestion
953            if (mSearchAutoComplete.isPopupShowing() &&
954                    mSearchAutoComplete.getListSelection() != ListView.INVALID_POSITION) {
955                return onSuggestionsKey(v, keyCode, event);
956            }
957
958            // If there is text in the query box, handle enter, and action keys
959            // The search key is handled by the dialog's onKeyDown().
960            if (!mSearchAutoComplete.isEmpty()) {
961                if (keyCode == KeyEvent.KEYCODE_ENTER
962                        && event.getAction() == KeyEvent.ACTION_UP) {
963                    v.cancelLongPress();
964
965                    if (mSearchable.autoUrlDetect()) {
966                        // If this is a url entered by the user & we displayed the 'Go' button which
967                        // the user clicked, launch the url instead of using it as a search query.
968                        if ((mSearchAutoCompleteImeOptions & EditorInfo.IME_MASK_ACTION)
969                                == EditorInfo.IME_ACTION_GO) {
970                            Uri uri = Uri.parse(fixUrl(mSearchAutoComplete.getText().toString()));
971                            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
972                            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
973                            launchIntent(intent);
974                        } else {
975                            // Launch as a regular search.
976                            launchQuerySearch();
977                        }
978                    }
979                    return true;
980                }
981                if (event.getAction() == KeyEvent.ACTION_DOWN) {
982                    SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
983                    if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) {
984                        launchQuerySearch(keyCode, actionKey.getQueryActionMsg());
985                        return true;
986                    }
987                }
988            }
989            return false;
990        }
991    };
992
993    @Override
994    public void cancel() {
995        if (!isShowing()) return;
996
997        // We made sure the IME was displayed, so also make sure it is closed
998        // when we go away.
999        InputMethodManager imm = (InputMethodManager)getContext()
1000                .getSystemService(Context.INPUT_METHOD_SERVICE);
1001        if (imm != null) {
1002            imm.hideSoftInputFromWindow(
1003                    getWindow().getDecorView().getWindowToken(), 0);
1004        }
1005
1006        super.cancel();
1007    }
1008
1009    /**
1010     * React to the user typing while in the suggestions list. First, check for action
1011     * keys. If not handled, try refocusing regular characters into the EditText.
1012     */
1013    private boolean onSuggestionsKey(View v, int keyCode, KeyEvent event) {
1014        // guard against possible race conditions (late arrival after dismiss)
1015        if (mSearchable == null) {
1016            return false;
1017        }
1018        if (mSuggestionsAdapter == null) {
1019            return false;
1020        }
1021        if (event.getAction() == KeyEvent.ACTION_DOWN) {
1022            if (DBG_LOG_TIMING) {
1023                dbgLogTiming("onSuggestionsKey()");
1024            }
1025
1026            // First, check for enter or search (both of which we'll treat as a "click")
1027            if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
1028                int position = mSearchAutoComplete.getListSelection();
1029                return launchSuggestion(position);
1030            }
1031
1032            // Next, check for left/right moves, which we use to "return" the user to the edit view
1033            if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
1034                // give "focus" to text editor, with cursor at the beginning if
1035                // left key, at end if right key
1036                // TODO: Reverse left/right for right-to-left languages, e.g. Arabic
1037                int selPoint = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ?
1038                        0 : mSearchAutoComplete.length();
1039                mSearchAutoComplete.setSelection(selPoint);
1040                mSearchAutoComplete.setListSelection(0);
1041                mSearchAutoComplete.clearListSelection();
1042                return true;
1043            }
1044
1045            // Next, check for an "up and out" move
1046            if (keyCode == KeyEvent.KEYCODE_DPAD_UP
1047                    && 0 == mSearchAutoComplete.getListSelection()) {
1048                restoreUserQuery();
1049                // let ACTV complete the move
1050                return false;
1051            }
1052
1053            // Next, check for an "action key"
1054            SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
1055            if ((actionKey != null) &&
1056                    ((actionKey.getSuggestActionMsg() != null) ||
1057                     (actionKey.getSuggestActionMsgColumn() != null))) {
1058                // launch suggestion using action key column
1059                int position = mSearchAutoComplete.getListSelection();
1060                if (position != ListView.INVALID_POSITION) {
1061                    Cursor c = mSuggestionsAdapter.getCursor();
1062                    if (c.moveToPosition(position)) {
1063                        final String actionMsg = getActionKeyMessage(c, actionKey);
1064                        if (actionMsg != null && (actionMsg.length() > 0)) {
1065                            return launchSuggestion(position, keyCode, actionMsg);
1066                        }
1067                    }
1068                }
1069            }
1070        }
1071        return false;
1072    }
1073
1074    /**
1075     * Launch a search for the text in the query text field.
1076     */
1077    protected void launchQuerySearch()  {
1078        launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null);
1079    }
1080
1081    /**
1082     * Launch a search for the text in the query text field.
1083     *
1084     * @param actionKey The key code of the action key that was pressed,
1085     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
1086     * @param actionMsg The message for the action key that was pressed,
1087     *        or <code>null</code> if none.
1088     */
1089    protected void launchQuerySearch(int actionKey, String actionMsg)  {
1090        String query = mSearchAutoComplete.getText().toString();
1091        Intent intent = createIntent(Intent.ACTION_SEARCH, null, null, query, null,
1092                actionKey, actionMsg);
1093        launchIntent(intent);
1094    }
1095
1096    /**
1097     * Launches an intent based on a suggestion.
1098     *
1099     * @param position The index of the suggestion to create the intent from.
1100     * @return true if a successful launch, false if could not (e.g. bad position).
1101     */
1102    protected boolean launchSuggestion(int position) {
1103        return launchSuggestion(position, KeyEvent.KEYCODE_UNKNOWN, null);
1104    }
1105
1106    /**
1107     * Launches an intent based on a suggestion.
1108     *
1109     * @param position The index of the suggestion to create the intent from.
1110     * @param actionKey The key code of the action key that was pressed,
1111     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
1112     * @param actionMsg The message for the action key that was pressed,
1113     *        or <code>null</code> if none.
1114     * @return true if a successful launch, false if could not (e.g. bad position).
1115     */
1116    protected boolean launchSuggestion(int position, int actionKey, String actionMsg) {
1117        Cursor c = mSuggestionsAdapter.getCursor();
1118        if ((c != null) && c.moveToPosition(position)) {
1119
1120            Intent intent = createIntentFromSuggestion(c, actionKey, actionMsg);
1121
1122            // report back about the click
1123            if (mGlobalSearchMode) {
1124                // in global search mode, do it via cursor
1125                mSuggestionsAdapter.callCursorOnClick(c, position);
1126            } else if (intent != null
1127                    && mPreviousComponents != null
1128                    && !mPreviousComponents.isEmpty()) {
1129                // in-app search (and we have pivoted in as told by mPreviousComponents,
1130                // which is used for keeping track of what we pop back to when we are pivoting into
1131                // in app search.)
1132                reportInAppClickToGlobalSearch(c, intent);
1133            }
1134
1135            // launch the intent
1136            launchIntent(intent);
1137
1138            return true;
1139        }
1140        return false;
1141    }
1142
1143    /**
1144     * Report a click from an in app search result back to global search for shortcutting porpoises.
1145     *
1146     * @param c The cursor that is pointing to the clicked position.
1147     * @param intent The intent that will be launched for the click.
1148     */
1149    private void reportInAppClickToGlobalSearch(Cursor c, Intent intent) {
1150        // for in app search, still tell global search via content provider
1151        Uri uri = getClickReportingUri();
1152        final ContentValues cv = new ContentValues();
1153        cv.put(SearchManager.SEARCH_CLICK_REPORT_COLUMN_QUERY, mUserQuery);
1154        final ComponentName source = mSearchable.getSearchActivity();
1155        cv.put(SearchManager.SEARCH_CLICK_REPORT_COLUMN_COMPONENT, source.flattenToShortString());
1156
1157        // grab the intent columns from the intent we created since it has additional
1158        // logic for falling back on the searchable default
1159        cv.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION, intent.getAction());
1160        cv.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, intent.getDataString());
1161        cv.put(SearchManager.SUGGEST_COLUMN_INTENT_COMPONENT_NAME,
1162                        intent.getStringExtra(SearchManager.COMPONENT_NAME_KEY));
1163
1164        // ensure the icons will work for global search
1165        cv.put(SearchManager.SUGGEST_COLUMN_ICON_1,
1166                        wrapIconForPackage(
1167                                source,
1168                                getColumnString(c, SearchManager.SUGGEST_COLUMN_ICON_1)));
1169        cv.put(SearchManager.SUGGEST_COLUMN_ICON_2,
1170                        wrapIconForPackage(
1171                                source,
1172                                getColumnString(c, SearchManager.SUGGEST_COLUMN_ICON_2)));
1173
1174        // the rest can be passed through directly
1175        cv.put(SearchManager.SUGGEST_COLUMN_FORMAT,
1176                getColumnString(c, SearchManager.SUGGEST_COLUMN_FORMAT));
1177        cv.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
1178                getColumnString(c, SearchManager.SUGGEST_COLUMN_TEXT_1));
1179        cv.put(SearchManager.SUGGEST_COLUMN_TEXT_2,
1180                getColumnString(c, SearchManager.SUGGEST_COLUMN_TEXT_2));
1181        cv.put(SearchManager.SUGGEST_COLUMN_QUERY,
1182                getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY));
1183        cv.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
1184                getColumnString(c, SearchManager.SUGGEST_COLUMN_SHORTCUT_ID));
1185        // note: deliberately omitting background color since it is only for global search
1186        // "more results" entries
1187        mContext.getContentResolver().insert(uri, cv);
1188    }
1189
1190    /**
1191     * @return A URI appropriate for reporting a click.
1192     */
1193    private Uri getClickReportingUri() {
1194        Uri.Builder uriBuilder = new Uri.Builder()
1195                .scheme(ContentResolver.SCHEME_CONTENT)
1196                .authority(SearchManager.SEARCH_CLICK_REPORT_AUTHORITY);
1197
1198        uriBuilder.appendPath(SearchManager.SEARCH_CLICK_REPORT_URI_PATH);
1199
1200        return uriBuilder
1201                .query("")     // TODO: Remove, workaround for a bug in Uri.writeToParcel()
1202                .fragment("")  // TODO: Remove, workaround for a bug in Uri.writeToParcel()
1203                .build();
1204    }
1205
1206    /**
1207     * Wraps an icon for a particular package.  If the icon is a resource id, it is converted into
1208     * an android.resource:// URI.
1209     *
1210     * @param source The source of the icon
1211     * @param icon The icon retrieved from a suggestion column
1212     * @return An icon string appropriate for the package.
1213     */
1214    private String wrapIconForPackage(ComponentName source, String icon) {
1215        if (icon == null || icon.length() == 0 || "0".equals(icon)) {
1216            // SearchManager specifies that null or zero can be returned to indicate
1217            // no icon. We also allow empty string.
1218            return null;
1219        } else if (!Character.isDigit(icon.charAt(0))){
1220            return icon;
1221        } else {
1222            String packageName = source.getPackageName();
1223            return new Uri.Builder()
1224                    .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
1225                    .authority(packageName)
1226                    .encodedPath(icon)
1227                    .toString();
1228        }
1229    }
1230
1231    /**
1232     * Launches an intent and dismisses the search dialog (unless the intent
1233     * is one of the special intents that modifies the state of the search dialog).
1234     */
1235    private void launchIntent(Intent intent) {
1236        if (intent == null) {
1237            return;
1238        }
1239        if (handleSpecialIntent(intent)){
1240            return;
1241        }
1242        dismiss();
1243        getContext().startActivity(intent);
1244    }
1245
1246    /**
1247     * Handles the special intent actions declared in {@link SearchManager}.
1248     *
1249     * @return <code>true</code> if the intent was handled.
1250     */
1251    private boolean handleSpecialIntent(Intent intent) {
1252        String action = intent.getAction();
1253        if (SearchManager.INTENT_ACTION_CHANGE_SEARCH_SOURCE.equals(action)) {
1254            handleChangeSourceIntent(intent);
1255            return true;
1256        }
1257        return false;
1258    }
1259
1260    /**
1261     * Handles {@link SearchManager#INTENT_ACTION_CHANGE_SEARCH_SOURCE}.
1262     */
1263    private void handleChangeSourceIntent(Intent intent) {
1264        Uri dataUri = intent.getData();
1265        if (dataUri == null) {
1266            Log.w(LOG_TAG, "SearchManager.INTENT_ACTION_CHANGE_SOURCE without intent data.");
1267            return;
1268        }
1269        ComponentName componentName = ComponentName.unflattenFromString(dataUri.toString());
1270        if (componentName == null) {
1271            Log.w(LOG_TAG, "Invalid ComponentName: " + dataUri);
1272            return;
1273        }
1274        if (DBG) Log.d(LOG_TAG, "Switching to " + componentName);
1275
1276        ComponentName previous = mLaunchComponent;
1277        if (!show(componentName, mAppSearchData, false)) {
1278            Log.w(LOG_TAG, "Failed to switch to source " + componentName);
1279            return;
1280        }
1281        pushPreviousComponent(previous);
1282
1283        String query = intent.getStringExtra(SearchManager.QUERY);
1284        setUserQuery(query);
1285        mSearchAutoComplete.showDropDown();
1286    }
1287
1288    /**
1289     * Sets the list item selection in the AutoCompleteTextView's ListView.
1290     */
1291    public void setListSelection(int index) {
1292        mSearchAutoComplete.setListSelection(index);
1293    }
1294
1295    /**
1296     * Saves the previous component that was searched, so that we can go
1297     * back to it.
1298     */
1299    private void pushPreviousComponent(ComponentName componentName) {
1300        if (mPreviousComponents == null) {
1301            mPreviousComponents = new ArrayList<ComponentName>();
1302        }
1303        mPreviousComponents.add(componentName);
1304    }
1305
1306    /**
1307     * Pops the previous component off the stack and returns it.
1308     *
1309     * @return The component name, or <code>null</code> if there was
1310     *         no previous component.
1311     */
1312    private ComponentName popPreviousComponent() {
1313        if (mPreviousComponents == null) {
1314            return null;
1315        }
1316        int size = mPreviousComponents.size();
1317        if (size == 0) {
1318            return null;
1319        }
1320        return mPreviousComponents.remove(size - 1);
1321    }
1322
1323    /**
1324     * Goes back to the previous component that was searched, if any.
1325     *
1326     * @return <code>true</code> if there was a previous component that we could go back to.
1327     */
1328    private boolean backToPreviousComponent() {
1329        ComponentName previous = popPreviousComponent();
1330        if (previous == null) {
1331            return false;
1332        }
1333        if (!show(previous, mAppSearchData, false)) {
1334            Log.w(LOG_TAG, "Failed to switch to source " + previous);
1335            return false;
1336        }
1337
1338        // must touch text to trigger suggestions
1339        // TODO: should this be the text as it was when the user left
1340        // the source that we are now going back to?
1341        String query = mSearchAutoComplete.getText().toString();
1342        setUserQuery(query);
1343
1344        return true;
1345    }
1346
1347    /**
1348     * When a particular suggestion has been selected, perform the various lookups required
1349     * to use the suggestion.  This includes checking the cursor for suggestion-specific data,
1350     * and/or falling back to the XML for defaults;  It also creates REST style Uri data when
1351     * the suggestion includes a data id.
1352     *
1353     * @param c The suggestions cursor, moved to the row of the user's selection
1354     * @param actionKey The key code of the action key that was pressed,
1355     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
1356     * @param actionMsg The message for the action key that was pressed,
1357     *        or <code>null</code> if none.
1358     * @return An intent for the suggestion at the cursor's position.
1359     */
1360    private Intent createIntentFromSuggestion(Cursor c, int actionKey, String actionMsg) {
1361        try {
1362            // use specific action if supplied, or default action if supplied, or fixed default
1363            String action = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_ACTION);
1364
1365            // some items are display only, or have effect via the cursor respond click reporting.
1366            if (SearchManager.INTENT_ACTION_NONE.equals(action)) {
1367                return null;
1368            }
1369
1370            if (action == null) {
1371                action = mSearchable.getSuggestIntentAction();
1372            }
1373            if (action == null) {
1374                action = Intent.ACTION_SEARCH;
1375            }
1376
1377            // use specific data if supplied, or default data if supplied
1378            String data = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
1379            if (data == null) {
1380                data = mSearchable.getSuggestIntentData();
1381            }
1382            // then, if an ID was provided, append it.
1383            if (data != null) {
1384                String id = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
1385                if (id != null) {
1386                    data = data + "/" + Uri.encode(id);
1387                }
1388            }
1389            Uri dataUri = (data == null) ? null : Uri.parse(data);
1390
1391            String componentName = getColumnString(
1392                    c, SearchManager.SUGGEST_COLUMN_INTENT_COMPONENT_NAME);
1393
1394            String query = getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY);
1395            String extraData = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA);
1396
1397            return createIntent(action, dataUri, extraData, query, componentName, actionKey,
1398                    actionMsg);
1399        } catch (RuntimeException e ) {
1400            int rowNum;
1401            try {                       // be really paranoid now
1402                rowNum = c.getPosition();
1403            } catch (RuntimeException e2 ) {
1404                rowNum = -1;
1405            }
1406            Log.w(LOG_TAG, "Search Suggestions cursor at row " + rowNum +
1407                            " returned exception" + e.toString());
1408            return null;
1409        }
1410    }
1411
1412    /**
1413     * Constructs an intent from the given information and the search dialog state.
1414     *
1415     * @param action Intent action.
1416     * @param data Intent data, or <code>null</code>.
1417     * @param extraData Data for {@link SearchManager#EXTRA_DATA_KEY} or <code>null</code>.
1418     * @param query Intent query, or <code>null</code>.
1419     * @param componentName Data for {@link SearchManager#COMPONENT_NAME_KEY} or <code>null</code>.
1420     * @param actionKey The key code of the action key that was pressed,
1421     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
1422     * @param actionMsg The message for the action key that was pressed,
1423     *        or <code>null</code> if none.
1424     * @return The intent.
1425     */
1426    private Intent createIntent(String action, Uri data, String extraData, String query,
1427            String componentName, int actionKey, String actionMsg) {
1428        // Now build the Intent
1429        Intent intent = new Intent(action);
1430        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
1431        if (data != null) {
1432            intent.setData(data);
1433        }
1434        intent.putExtra(SearchManager.USER_QUERY, mUserQuery);
1435        if (query != null) {
1436            intent.putExtra(SearchManager.QUERY, query);
1437        }
1438        if (extraData != null) {
1439            intent.putExtra(SearchManager.EXTRA_DATA_KEY, extraData);
1440        }
1441        if (componentName != null) {
1442            intent.putExtra(SearchManager.COMPONENT_NAME_KEY, componentName);
1443        }
1444        if (mAppSearchData != null) {
1445            intent.putExtra(SearchManager.APP_DATA, mAppSearchData);
1446        }
1447        if (actionKey != KeyEvent.KEYCODE_UNKNOWN) {
1448            intent.putExtra(SearchManager.ACTION_KEY, actionKey);
1449            intent.putExtra(SearchManager.ACTION_MSG, actionMsg);
1450        }
1451        // attempt to enforce security requirement (no 3rd-party intents)
1452        intent.setComponent(mSearchable.getSearchActivity());
1453        return intent;
1454    }
1455
1456    /**
1457     * For a given suggestion and a given cursor row, get the action message.  If not provided
1458     * by the specific row/column, also check for a single definition (for the action key).
1459     *
1460     * @param c The cursor providing suggestions
1461     * @param actionKey The actionkey record being examined
1462     *
1463     * @return Returns a string, or null if no action key message for this suggestion
1464     */
1465    private static String getActionKeyMessage(Cursor c, SearchableInfo.ActionKeyInfo actionKey) {
1466        String result = null;
1467        // check first in the cursor data, for a suggestion-specific message
1468        final String column = actionKey.getSuggestActionMsgColumn();
1469        if (column != null) {
1470            result = SuggestionsAdapter.getColumnString(c, column);
1471        }
1472        // If the cursor didn't give us a message, see if there's a single message defined
1473        // for the actionkey (for all suggestions)
1474        if (result == null) {
1475            result = actionKey.getSuggestActionMsg();
1476        }
1477        return result;
1478    }
1479
1480    /**
1481     * Local subclass for AutoCompleteTextView.
1482     */
1483    public static class SearchAutoComplete extends AutoCompleteTextView {
1484
1485        private int mThreshold;
1486        private SearchDialog mSearchDialog;
1487
1488        public SearchAutoComplete(Context context) {
1489            super(context);
1490            mThreshold = getThreshold();
1491        }
1492
1493        public SearchAutoComplete(Context context, AttributeSet attrs) {
1494            super(context, attrs);
1495            mThreshold = getThreshold();
1496        }
1497
1498        public SearchAutoComplete(Context context, AttributeSet attrs, int defStyle) {
1499            super(context, attrs, defStyle);
1500            mThreshold = getThreshold();
1501        }
1502
1503        private void setSearchDialog(SearchDialog searchDialog) {
1504            mSearchDialog = searchDialog;
1505        }
1506
1507        @Override
1508        public void setThreshold(int threshold) {
1509            super.setThreshold(threshold);
1510            mThreshold = threshold;
1511        }
1512
1513        /**
1514         * Returns true if the text field is empty, or contains only whitespace.
1515         */
1516        private boolean isEmpty() {
1517            return TextUtils.getTrimmedLength(getText()) == 0;
1518        }
1519
1520        /**
1521         * We override this method to avoid replacing the query box text
1522         * when a suggestion is clicked.
1523         */
1524        @Override
1525        protected void replaceText(CharSequence text) {
1526        }
1527
1528        /**
1529         * We override this method to avoid an extra onItemClick being called on the
1530         * drop-down's OnItemClickListener by {@link AutoCompleteTextView#onKeyUp(int, KeyEvent)}
1531         * when an item is clicked with the trackball.
1532         */
1533        @Override
1534        public void performCompletion() {
1535        }
1536
1537        /**
1538         * We override this method so that we can allow a threshold of zero, which ACTV does not.
1539         */
1540        @Override
1541        public boolean enoughToFilter() {
1542            return mThreshold <= 0 || super.enoughToFilter();
1543        }
1544
1545        /**
1546         * {@link AutoCompleteTextView#onKeyPreIme(int, KeyEvent)}) dismisses the drop-down on BACK,
1547         * so we must override this method to modify the BACK behavior.
1548         */
1549        @Override
1550        public boolean onKeyPreIme(int keyCode, KeyEvent event) {
1551            if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_DOWN) {
1552                if (mSearchDialog.backToPreviousComponent()) {
1553                    return true;
1554                }
1555                return false; // will dismiss soft keyboard if necessary
1556            }
1557            return false;
1558        }
1559    }
1560
1561    protected boolean handleBackKey(int keyCode, KeyEvent event) {
1562        if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_DOWN) {
1563            if (backToPreviousComponent()) {
1564                return true;
1565            }
1566            cancel();
1567            return true;
1568        }
1569        return false;
1570    }
1571
1572    /**
1573     * Implements OnItemClickListener
1574     */
1575    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
1576        if (DBG) Log.d(LOG_TAG, "onItemClick() position " + position);
1577        launchSuggestion(position);
1578    }
1579
1580    /**
1581     * Implements OnItemSelectedListener
1582     */
1583     public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
1584         if (DBG) Log.d(LOG_TAG, "onItemSelected() position " + position);
1585         // A suggestion has been selected, rewrite the query if possible,
1586         // otherwise the restore the original query.
1587         if (REWRITE_QUERIES) {
1588             rewriteQueryFromSuggestion(position);
1589         }
1590     }
1591
1592     /**
1593      * Implements OnItemSelectedListener
1594      */
1595     public void onNothingSelected(AdapterView<?> parent) {
1596         if (DBG) Log.d(LOG_TAG, "onNothingSelected()");
1597     }
1598
1599     /**
1600      * Query rewriting.
1601      */
1602
1603     private void rewriteQueryFromSuggestion(int position) {
1604         Cursor c = mSuggestionsAdapter.getCursor();
1605         if (c == null) {
1606             return;
1607         }
1608         if (c.moveToPosition(position)) {
1609             // Get the new query from the suggestion.
1610             CharSequence newQuery = mSuggestionsAdapter.convertToString(c);
1611             if (newQuery != null) {
1612                 // The suggestion rewrites the query.
1613                 if (DBG) Log.d(LOG_TAG, "Rewriting query to '" + newQuery + "'");
1614                 // Update the text field, without getting new suggestions.
1615                 setQuery(newQuery);
1616             } else {
1617                 // The suggestion does not rewrite the query, restore the user's query.
1618                 if (DBG) Log.d(LOG_TAG, "Suggestion gives no rewrite, restoring user query.");
1619                 restoreUserQuery();
1620             }
1621         } else {
1622             // We got a bad position, restore the user's query.
1623             Log.w(LOG_TAG, "Bad suggestion position: " + position);
1624             restoreUserQuery();
1625         }
1626     }
1627
1628     /**
1629      * Restores the query entered by the user if needed.
1630      */
1631     private void restoreUserQuery() {
1632         if (DBG) Log.d(LOG_TAG, "Restoring query to '" + mUserQuery + "'");
1633         setQuery(mUserQuery);
1634     }
1635
1636     /**
1637      * Sets the text in the query box, without updating the suggestions.
1638      */
1639     private void setQuery(CharSequence query) {
1640         mSearchAutoComplete.setText(query, false);
1641         if (query != null) {
1642             mSearchAutoComplete.setSelection(query.length());
1643         }
1644     }
1645
1646     /**
1647      * Sets the text in the query box, updating the suggestions.
1648      */
1649     private void setUserQuery(String query) {
1650         if (query == null) {
1651             query = "";
1652         }
1653         mUserQuery = query;
1654         mSearchAutoComplete.setText(query);
1655         mSearchAutoComplete.setSelection(query.length());
1656     }
1657
1658    /**
1659     * Debugging Support
1660     */
1661
1662    /**
1663     * For debugging only, sample the millisecond clock and log it.
1664     * Uses AtomicLong so we can use in multiple threads
1665     */
1666    private AtomicLong mLastLogTime = new AtomicLong(SystemClock.uptimeMillis());
1667    private void dbgLogTiming(final String caller) {
1668        long millis = SystemClock.uptimeMillis();
1669        long oldTime = mLastLogTime.getAndSet(millis);
1670        long delta = millis - oldTime;
1671        final String report = millis + " (+" + delta + ") ticks for Search keystroke in " + caller;
1672        Log.d(LOG_TAG,report);
1673    }
1674}
1675