SearchView.java revision 87c5025fb8a21348ead7cd7e6fa08aa5dc0f501e
1/*
2 * Copyright (C) 2010 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.widget;
18
19import static android.widget.SuggestionsAdapter.getColumnString;
20
21import com.android.internal.R;
22
23import android.app.PendingIntent;
24import android.app.SearchManager;
25import android.app.SearchableInfo;
26import android.content.ActivityNotFoundException;
27import android.content.ComponentName;
28import android.content.Context;
29import android.content.Intent;
30import android.content.pm.PackageManager;
31import android.content.pm.ResolveInfo;
32import android.content.res.Resources;
33import android.content.res.TypedArray;
34import android.database.Cursor;
35import android.graphics.Rect;
36import android.graphics.drawable.Drawable;
37import android.net.Uri;
38import android.os.Bundle;
39import android.speech.RecognizerIntent;
40import android.text.Editable;
41import android.text.TextUtils;
42import android.text.TextWatcher;
43import android.util.AttributeSet;
44import android.util.Log;
45import android.view.KeyEvent;
46import android.view.LayoutInflater;
47import android.view.View;
48import android.view.inputmethod.InputMethodManager;
49import android.widget.AdapterView.OnItemClickListener;
50import android.widget.AdapterView.OnItemSelectedListener;
51import android.widget.TextView.OnEditorActionListener;
52
53import java.util.WeakHashMap;
54
55/**
56 * Provides the user interface elements for the user to enter a search query and submit a
57 * request to a search provider. Shows a list of query suggestions or results, if
58 * available and allows the user to pick a suggestion or result to launch into.
59 *
60 * <p>
61 * <b>XML attributes</b>
62 * <p>
63 * See {@link android.R.styleable#SearchView SearchView Attributes},
64 * {@link android.R.styleable#View View Attributes}
65 *
66 * @attr ref android.R.styleable#SearchView_iconifiedByDefault
67 * @attr ref android.R.styleable#SearchView_maxWidth
68 */
69public class SearchView extends LinearLayout {
70
71    private static final boolean DBG = false;
72    private static final String LOG_TAG = "SearchView";
73
74    private OnQueryChangeListener mOnQueryChangeListener;
75    private OnCloseListener mOnCloseListener;
76    private OnFocusChangeListener mOnQueryTextFocusChangeListener;
77    private OnSuggestionSelectionListener mOnSuggestionListener;
78
79    private boolean mIconifiedByDefault;
80    private boolean mIconified;
81    private CursorAdapter mSuggestionsAdapter;
82    private View mSearchButton;
83    private View mSubmitButton;
84    private View mCloseButton;
85    private View mSearchEditFrame;
86    private View mVoiceButton;
87    private AutoCompleteTextView mQueryTextView;
88    private boolean mSubmitButtonEnabled;
89    private CharSequence mQueryHint;
90    private boolean mQueryRefinement;
91    private boolean mClearingFocus;
92    private int mMaxWidth;
93
94    private SearchableInfo mSearchable;
95
96    // For voice searching
97    private final Intent mVoiceWebSearchIntent;
98    private final Intent mVoiceAppSearchIntent;
99
100    // A weak map of drawables we've gotten from other packages, so we don't load them
101    // more than once.
102    private final WeakHashMap<String, Drawable.ConstantState> mOutsideDrawablesCache =
103            new WeakHashMap<String, Drawable.ConstantState>();
104
105    /**
106     * Callbacks for changes to the query text.
107     */
108    public interface OnQueryChangeListener {
109
110        /**
111         * Called when the user submits the query. This could be due to a key press on the
112         * keyboard or due to pressing a submit button.
113         * The listener can override the standard behavior by returning true
114         * to indicate that it has handled the submit request. Otherwise return false to
115         * let the SearchView handle the submission by launching any associated intent.
116         *
117         * @param query the query text that is to be submitted
118         *
119         * @return true if the query has been handled by the listener, false to let the
120         * SearchView perform the default action.
121         */
122        boolean onSubmitQuery(String query);
123
124        /**
125         * Called when the query text is changed by the user.
126         *
127         * @param newText the new content of the query text field.
128         *
129         * @return false if the SearchView should perform the default action of showing any
130         * suggestions if available, true if the action was handled by the listener.
131         */
132        boolean onQueryTextChanged(String newText);
133    }
134
135    public interface OnCloseListener {
136
137        /**
138         * The user is attempting to close the SearchView.
139         *
140         * @return true if the listener wants to override the default behavior of clearing the
141         * text field and dismissing it, false otherwise.
142         */
143        boolean onClose();
144    }
145
146    /**
147     * Callback interface for selection events on suggestions. These callbacks
148     * are only relevant when a SearchableInfo has been specified by {@link #setSearchableInfo}.
149     */
150    public interface OnSuggestionSelectionListener {
151
152        /**
153         * Called when a suggestion was selected by navigating to it.
154         * @param position the absolute position in the list of suggestions.
155         *
156         * @return true if the listener handles the event and wants to override the default
157         * behavior of possibly rewriting the query based on the selected item, false otherwise.
158         */
159        boolean onSuggestionSelected(int position);
160
161        /**
162         * Called when a suggestion was clicked.
163         * @param position the absolute position of the clicked item in the list of suggestions.
164         *
165         * @return true if the listener handles the event and wants to override the default
166         * behavior of launching any intent or submitting a search query specified on that item.
167         * Return false otherwise.
168         */
169        boolean onSuggestionClicked(int position);
170    }
171
172    public SearchView(Context context) {
173        this(context, null);
174    }
175
176    public SearchView(Context context, AttributeSet attrs) {
177        super(context, attrs);
178
179        LayoutInflater inflater = (LayoutInflater) context
180                .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
181        inflater.inflate(R.layout.search_view, this, true);
182
183        mSearchButton = findViewById(R.id.search_button);
184        mQueryTextView = (AutoCompleteTextView) findViewById(R.id.search_src_text);
185        mSearchEditFrame = findViewById(R.id.search_edit_frame);
186        mSubmitButton = findViewById(R.id.search_go_btn);
187        mCloseButton = findViewById(R.id.search_close_btn);
188        mVoiceButton = findViewById(R.id.search_voice_btn);
189
190        mSearchButton.setOnClickListener(mOnClickListener);
191        mCloseButton.setOnClickListener(mOnClickListener);
192        mSubmitButton.setOnClickListener(mOnClickListener);
193        mVoiceButton.setOnClickListener(mOnClickListener);
194
195        mQueryTextView.addTextChangedListener(mTextWatcher);
196        mQueryTextView.setOnEditorActionListener(mOnEditorActionListener);
197        mQueryTextView.setOnItemClickListener(mOnItemClickListener);
198        mQueryTextView.setOnItemSelectedListener(mOnItemSelectedListener);
199        // Inform any listener of focus changes
200        mQueryTextView.setOnFocusChangeListener(new OnFocusChangeListener() {
201
202            public void onFocusChange(View v, boolean hasFocus) {
203                if (mOnQueryTextFocusChangeListener != null) {
204                    mOnQueryTextFocusChangeListener.onFocusChange(SearchView.this, hasFocus);
205                }
206            }
207        });
208
209        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SearchView, 0, 0);
210        setIconifiedByDefault(a.getBoolean(R.styleable.SearchView_iconifiedByDefault, true));
211        int maxWidth = a.getDimensionPixelSize(R.styleable.SearchView_maxWidth, -1);
212        if (maxWidth != -1) {
213            setMaxWidth(maxWidth);
214        }
215        a.recycle();
216
217        // Save voice intent for later queries/launching
218        mVoiceWebSearchIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH);
219        mVoiceWebSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
220        mVoiceWebSearchIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
221                RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH);
222
223        mVoiceAppSearchIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
224        mVoiceAppSearchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
225
226        updateViewsVisibility(mIconifiedByDefault);
227    }
228
229    /**
230     * Sets the SearchableInfo for this SearchView. Properties in the SearchableInfo are used
231     * to display labels, hints, suggestions, create intents for launching search results screens
232     * and controlling other affordances such as a voice button.
233     *
234     * @param searchable a SearchableInfo can be retrieved from the SearchManager, for a specific
235     * activity or a global search provider.
236     */
237    public void setSearchableInfo(SearchableInfo searchable) {
238        mSearchable = searchable;
239        if (mSearchable != null) {
240            updateSearchAutoComplete();
241        }
242        updateViewsVisibility(mIconifiedByDefault);
243    }
244
245    /** @hide */
246    @Override
247    public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
248        if (mClearingFocus || isIconified()) return false;
249        return mQueryTextView.requestFocus(direction, previouslyFocusedRect);
250    }
251
252    /** @hide */
253    @Override
254    public void clearFocus() {
255        mClearingFocus = true;
256        super.clearFocus();
257        mQueryTextView.clearFocus();
258        setImeVisibility(false);
259        mClearingFocus = false;
260    }
261
262    /**
263     * Sets a listener for user actions within the SearchView.
264     *
265     * @param listener the listener object that receives callbacks when the user performs
266     * actions in the SearchView such as clicking on buttons or typing a query.
267     */
268    public void setOnQueryChangeListener(OnQueryChangeListener listener) {
269        mOnQueryChangeListener = listener;
270    }
271
272    /**
273     * Sets a listener to inform when the user closes the SearchView.
274     *
275     * @param listener the listener to call when the user closes the SearchView.
276     */
277    public void setOnCloseListener(OnCloseListener listener) {
278        mOnCloseListener = listener;
279    }
280
281    /**
282     * Sets a listener to inform when the focus of the query text field changes.
283     *
284     * @param listener the listener to inform of focus changes.
285     */
286    public void setOnQueryTextFocusChangeListener(OnFocusChangeListener listener) {
287        mOnQueryTextFocusChangeListener = listener;
288    }
289
290    /**
291     * Sets a listener to inform when a suggestion is focused or clicked.
292     *
293     * @param listener the listener to inform of suggestion selection events.
294     */
295    public void setOnSuggestionSelectionListener(OnSuggestionSelectionListener listener) {
296        mOnSuggestionListener = listener;
297    }
298
299    /**
300     * Sets a query string in the text field and optionally submits the query as well.
301     *
302     * @param query the query string. This replaces any query text already present in the
303     * text field.
304     * @param submit whether to submit the query right now or only update the contents of
305     * text field.
306     */
307    public void setQuery(CharSequence query, boolean submit) {
308        mQueryTextView.setText(query);
309        if (query != null) {
310            mQueryTextView.setSelection(query.length());
311        }
312
313        // If the query is not empty and submit is requested, submit the query
314        if (submit && !TextUtils.isEmpty(query)) {
315            onSubmitQuery();
316        }
317    }
318
319    /**
320     * Sets the hint text to display in the query text field. This overrides any hint specified
321     * in the SearchableInfo.
322     *
323     * @param hint the hint text to display
324     */
325    public void setQueryHint(CharSequence hint) {
326        mQueryHint = hint;
327        updateQueryHint();
328    }
329
330    /**
331     * Sets the default or resting state of the search field. If true, a single search icon is
332     * shown by default and expands to show the text field and other buttons when pressed. Also,
333     * if the default state is iconified, then it collapses to that state when the close button
334     * is pressed. Changes to this property will take effect immediately.
335     *
336     * <p>The default value is false.</p>
337     *
338     * @param iconified whether the search field should be iconified by default
339     */
340    public void setIconifiedByDefault(boolean iconified) {
341        if (mIconifiedByDefault == iconified) return;
342        mIconifiedByDefault = iconified;
343        updateViewsVisibility(iconified);
344        setImeVisibility(!iconified);
345    }
346
347    /**
348     * Returns the default iconified state of the search field.
349     * @return
350     */
351    public boolean isIconfiedByDefault() {
352        return mIconifiedByDefault;
353    }
354
355    /**
356     * Iconifies or expands the SearchView. Any query text is cleared when iconified. This is
357     * a temporary state and does not override the default iconified state set by
358     * {@link #setIconifiedByDefault(boolean)}. If the default state is iconified, then
359     * a false here will only be valid until the user closes the field. And if the default
360     * state is expanded, then a true here will only clear the text field and not close it.
361     *
362     * @param iconify a true value will collapse the SearchView to an icon, while a false will
363     * expand it.
364     */
365    public void setIconified(boolean iconify) {
366        if (iconify) {
367            onCloseClicked();
368        } else {
369            onSearchClicked();
370        }
371    }
372
373    /**
374     * Returns the current iconified state of the SearchView.
375     *
376     * @return true if the SearchView is currently iconified, false if the search field is
377     * fully visible.
378     */
379    public boolean isIconified() {
380        return mIconified;
381    }
382
383    /**
384     * Enables showing a submit button when the query is non-empty. In cases where the SearchView
385     * is being used to filter the contents of the current activity and doesn't launch a separate
386     * results activity, then the submit button should be disabled.
387     *
388     * @param enabled true to show a submit button for submitting queries, false if a submit
389     * button is not required.
390     */
391    public void setSubmitButtonEnabled(boolean enabled) {
392        mSubmitButtonEnabled = enabled;
393        updateViewsVisibility(isIconified());
394    }
395
396    /**
397     * Returns whether the submit button is enabled when necessary or never displayed.
398     *
399     * @return whether the submit button is enabled automatically when necessary
400     */
401    public boolean isSubmitButtonEnabled() {
402        return mSubmitButtonEnabled;
403    }
404
405    /**
406     * Specifies if a query refinement button should be displayed alongside each suggestion
407     * or if it should depend on the flags set in the individual items retrieved from the
408     * suggestions provider. Clicking on the query refinement button will replace the text
409     * in the query text field with the text from the suggestion. This flag only takes effect
410     * if a SearchableInfo has been specified with {@link #setSearchableInfo(SearchableInfo)}
411     * and not when using a custom adapter.
412     *
413     * @param enable true if all items should have a query refinement button, false if only
414     * those items that have a query refinement flag set should have the button.
415     *
416     * @see SearchManager#SUGGEST_COLUMN_FLAGS
417     * @see SearchManager#FLAG_QUERY_REFINEMENT
418     */
419    public void setQueryRefinementEnabled(boolean enable) {
420        mQueryRefinement = enable;
421        if (mSuggestionsAdapter instanceof SuggestionsAdapter) {
422            ((SuggestionsAdapter) mSuggestionsAdapter).setQueryRefinement(
423                    enable ? SuggestionsAdapter.REFINE_ALL : SuggestionsAdapter.REFINE_BY_ENTRY);
424        }
425    }
426
427    /**
428     * Returns whether query refinement is enabled for all items or only specific ones.
429     * @return true if enabled for all items, false otherwise.
430     */
431    public boolean isQueryRefinementEnabled() {
432        return mQueryRefinement;
433    }
434
435    /**
436     * You can set a custom adapter if you wish. Otherwise the default adapter is used to
437     * display the suggestions from the suggestions provider associated with the SearchableInfo.
438     *
439     * @see #setSearchableInfo(SearchableInfo)
440     */
441    public void setSuggestionsAdapter(CursorAdapter adapter) {
442        mSuggestionsAdapter = adapter;
443
444        mQueryTextView.setAdapter(mSuggestionsAdapter);
445    }
446
447    /**
448     * Returns the adapter used for suggestions, if any.
449     * @return the suggestions adapter
450     */
451    public CursorAdapter getSuggestionsAdapter() {
452        return mSuggestionsAdapter;
453    }
454
455    /**
456     * Makes the view at most this many pixels wide
457     *
458     * @attr ref android.R.styleable#SearchView_maxWidth
459     */
460    public void setMaxWidth(int maxpixels) {
461        mMaxWidth = maxpixels;
462
463        requestLayout();
464    }
465
466    @Override
467    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
468        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
469        int width = MeasureSpec.getSize(widthMeasureSpec);
470
471        if ((widthMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.EXACTLY) && mMaxWidth > 0
472                && width > mMaxWidth) {
473            super.onMeasure(MeasureSpec.makeMeasureSpec(mMaxWidth, widthMode), heightMeasureSpec);
474        } else {
475            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
476        }
477    }
478
479    private void updateViewsVisibility(final boolean collapsed) {
480        mIconified = collapsed;
481        // Visibility of views that are visible when collapsed
482        final int visCollapsed = collapsed ? VISIBLE : GONE;
483        // Visibility of views that are visible when expanded
484        final int visExpanded = collapsed ? GONE : VISIBLE;
485        // Is there text in the query
486        final boolean hasText = !TextUtils.isEmpty(mQueryTextView.getText());
487
488        mSearchButton.setVisibility(visCollapsed);
489        mSubmitButton.setVisibility(mSubmitButtonEnabled && hasText ? visExpanded : GONE);
490        mSearchEditFrame.setVisibility(visExpanded);
491        updateVoiceButton(!hasText);
492        requestLayout();
493        invalidate();
494    }
495
496    private void setImeVisibility(boolean visible) {
497        InputMethodManager imm = (InputMethodManager)
498        getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
499
500        // We made sure the IME was displayed, so also make sure it is closed
501        // when we go away.
502        if (imm != null) {
503            if (visible) {
504                imm.showSoftInputUnchecked(0, null);
505            } else {
506                imm.hideSoftInputFromWindow(getWindowToken(), 0);
507            }
508        }
509    }
510
511    /**
512     * Called by the SuggestionsAdapter
513     * @hide
514     */
515    /* package */void onQueryRefine(CharSequence queryText) {
516        setQuery(queryText);
517    }
518
519    private final OnClickListener mOnClickListener = new OnClickListener() {
520
521        public void onClick(View v) {
522            if (v == mSearchButton) {
523                onSearchClicked();
524            } else if (v == mCloseButton) {
525                onCloseClicked();
526            } else if (v == mSubmitButton) {
527                onSubmitQuery();
528            } else if (v == mVoiceButton) {
529                onVoiceClicked();
530            }
531        }
532    };
533
534    /**
535     * Handles the key down event for dealing with action keys.
536     *
537     * @param keyCode This is the keycode of the typed key, and is the same value as
538     *        found in the KeyEvent parameter.
539     * @param event The complete event record for the typed key
540     *
541     * @return true if the event was handled here, or false if not.
542     */
543    @Override
544    public boolean onKeyDown(int keyCode, KeyEvent event) {
545        if (mSearchable == null) {
546            return false;
547        }
548
549        // if it's an action specified by the searchable activity, launch the
550        // entered query with the action key
551        SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
552        if ((actionKey != null) && (actionKey.getQueryActionMsg() != null)) {
553            launchQuerySearch(keyCode, actionKey.getQueryActionMsg(), mQueryTextView.getText()
554                    .toString());
555            return true;
556        }
557
558        return super.onKeyDown(keyCode, event);
559    }
560
561    private void updateQueryHint() {
562        if (mQueryHint != null) {
563            mQueryTextView.setHint(mQueryHint);
564        } else if (mSearchable != null) {
565            CharSequence hint = null;
566            int hintId = mSearchable.getHintId();
567            if (hintId != 0) {
568                hint = getContext().getString(hintId);
569            }
570            if (hint != null) {
571                mQueryTextView.setHint(hint);
572            }
573        }
574    }
575
576    /**
577     * Updates the auto-complete text view.
578     */
579    private void updateSearchAutoComplete() {
580        // close any existing suggestions adapter
581        //closeSuggestionsAdapter();
582
583        mQueryTextView.setDropDownAnimationStyle(0); // no animation
584        mQueryTextView.setThreshold(mSearchable.getSuggestThreshold());
585
586        // attach the suggestions adapter, if suggestions are available
587        // The existence of a suggestions authority is the proxy for "suggestions available here"
588        if (mSearchable.getSuggestAuthority() != null) {
589            mSuggestionsAdapter = new SuggestionsAdapter(getContext(),
590                    this, mSearchable, mOutsideDrawablesCache);
591            mQueryTextView.setAdapter(mSuggestionsAdapter);
592            ((SuggestionsAdapter) mSuggestionsAdapter).setQueryRefinement(
593                    mQueryRefinement ? SuggestionsAdapter.REFINE_ALL
594                    : SuggestionsAdapter.REFINE_BY_ENTRY);
595        }
596    }
597
598    /**
599     * Update the visibility of the voice button.  There are actually two voice search modes,
600     * either of which will activate the button.
601     * @param empty whether the search query text field is empty. If it is, then the other
602     * criteria apply to make the voice button visible. Otherwise the voice button will not
603     * be visible - i.e., if the user has typed a query, remove the voice button.
604     */
605    private void updateVoiceButton(boolean empty) {
606        int visibility = View.GONE;
607        if (mSearchable != null && mSearchable.getVoiceSearchEnabled() && empty
608                && !isIconified()) {
609            Intent testIntent = null;
610            if (mSearchable.getVoiceSearchLaunchWebSearch()) {
611                testIntent = mVoiceWebSearchIntent;
612            } else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
613                testIntent = mVoiceAppSearchIntent;
614            }
615            if (testIntent != null) {
616                ResolveInfo ri = getContext().getPackageManager().resolveActivity(testIntent,
617                        PackageManager.MATCH_DEFAULT_ONLY);
618                if (ri != null) {
619                    visibility = View.VISIBLE;
620                }
621            }
622        }
623        mVoiceButton.setVisibility(visibility);
624    }
625
626    private final OnEditorActionListener mOnEditorActionListener = new OnEditorActionListener() {
627
628        /**
629         * Called when the input method default action key is pressed.
630         */
631        public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
632            onSubmitQuery();
633            return true;
634        }
635    };
636
637    private void onTextChanged(CharSequence newText) {
638        CharSequence text = mQueryTextView.getText();
639        boolean hasText = !TextUtils.isEmpty(text);
640        if (isSubmitButtonEnabled()) {
641            mSubmitButton.setVisibility(hasText ? VISIBLE : GONE);
642            requestLayout();
643            invalidate();
644        }
645        updateVoiceButton(!hasText);
646        if (mOnQueryChangeListener != null) {
647            mOnQueryChangeListener.onQueryTextChanged(newText.toString());
648        }
649    }
650
651    private void onSubmitQuery() {
652        CharSequence query = mQueryTextView.getText();
653        if (!TextUtils.isEmpty(query)) {
654            if (mOnQueryChangeListener == null
655                    || !mOnQueryChangeListener.onSubmitQuery(query.toString())) {
656                if (mSearchable != null) {
657                    launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null, query.toString());
658                    setImeVisibility(false);
659                }
660                dismissSuggestions();
661            }
662        }
663    }
664
665    private void dismissSuggestions() {
666        mQueryTextView.dismissDropDown();
667    }
668
669    private void onCloseClicked() {
670        if (mOnCloseListener == null || !mOnCloseListener.onClose()) {
671            CharSequence text = mQueryTextView.getText();
672            if (TextUtils.isEmpty(text)) {
673                // query field already empty, hide the keyboard and remove focus
674                clearFocus();
675                setImeVisibility(false);
676            } else {
677                mQueryTextView.setText("");
678            }
679            updateViewsVisibility(mIconifiedByDefault);
680            if (mIconifiedByDefault) setImeVisibility(false);
681        }
682    }
683
684    private void onSearchClicked() {
685        mQueryTextView.requestFocus();
686        updateViewsVisibility(false);
687        setImeVisibility(true);
688    }
689
690    private void onVoiceClicked() {
691        // guard against possible race conditions
692        if (mSearchable == null) {
693            return;
694        }
695        SearchableInfo searchable = mSearchable;
696        try {
697            if (searchable.getVoiceSearchLaunchWebSearch()) {
698                Intent webSearchIntent = createVoiceWebSearchIntent(mVoiceWebSearchIntent,
699                        searchable);
700                getContext().startActivity(webSearchIntent);
701            } else if (searchable.getVoiceSearchLaunchRecognizer()) {
702                Intent appSearchIntent = createVoiceAppSearchIntent(mVoiceAppSearchIntent,
703                        searchable);
704                getContext().startActivity(appSearchIntent);
705            }
706        } catch (ActivityNotFoundException e) {
707            // Should not happen, since we check the availability of
708            // voice search before showing the button. But just in case...
709            Log.w(LOG_TAG, "Could not find voice search activity");
710        }
711    }
712
713    private final OnItemClickListener mOnItemClickListener = new OnItemClickListener() {
714
715        /**
716         * Implements OnItemClickListener
717         */
718        public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
719            if (DBG)
720                Log.d(LOG_TAG, "onItemClick() position " + position);
721            if (mOnSuggestionListener == null
722                    || !mOnSuggestionListener.onSuggestionClicked(position)) {
723                launchSuggestion(position, KeyEvent.KEYCODE_UNKNOWN, null);
724                setImeVisibility(false);
725                dismissSuggestions();
726            }
727        }
728    };
729
730    private final OnItemSelectedListener mOnItemSelectedListener = new OnItemSelectedListener() {
731
732        /**
733         * Implements OnItemSelectedListener
734         */
735        public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
736            if (DBG)
737                Log.d(LOG_TAG, "onItemSelected() position " + position);
738            // A suggestion has been selected, rewrite the query if possible,
739            // otherwise the restore the original query.
740            if (mOnSuggestionListener == null
741                    || !mOnSuggestionListener.onSuggestionSelected(position)) {
742                rewriteQueryFromSuggestion(position);
743            }
744        }
745
746        /**
747         * Implements OnItemSelectedListener
748         */
749        public void onNothingSelected(AdapterView<?> parent) {
750            if (DBG)
751                Log.d(LOG_TAG, "onNothingSelected()");
752        }
753    };
754
755    /**
756     * Query rewriting.
757     */
758    private void rewriteQueryFromSuggestion(int position) {
759        CharSequence oldQuery = mQueryTextView.getText();
760        Cursor c = mSuggestionsAdapter.getCursor();
761        if (c == null) {
762            return;
763        }
764        if (c.moveToPosition(position)) {
765            // Get the new query from the suggestion.
766            CharSequence newQuery = mSuggestionsAdapter.convertToString(c);
767            if (newQuery != null) {
768                // The suggestion rewrites the query.
769                // Update the text field, without getting new suggestions.
770                setQuery(newQuery);
771            } else {
772                // The suggestion does not rewrite the query, restore the user's query.
773                setQuery(oldQuery);
774            }
775        } else {
776            // We got a bad position, restore the user's query.
777            setQuery(oldQuery);
778        }
779    }
780
781    /**
782     * Launches an intent based on a suggestion.
783     *
784     * @param position The index of the suggestion to create the intent from.
785     * @param actionKey The key code of the action key that was pressed,
786     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
787     * @param actionMsg The message for the action key that was pressed,
788     *        or <code>null</code> if none.
789     * @return true if a successful launch, false if could not (e.g. bad position).
790     */
791    private boolean launchSuggestion(int position, int actionKey, String actionMsg) {
792        Cursor c = mSuggestionsAdapter.getCursor();
793        if ((c != null) && c.moveToPosition(position)) {
794
795            Intent intent = createIntentFromSuggestion(c, actionKey, actionMsg);
796
797            // launch the intent
798            launchIntent(intent);
799
800            return true;
801        }
802        return false;
803    }
804
805    /**
806     * Launches an intent, including any special intent handling.
807     */
808    private void launchIntent(Intent intent) {
809        if (intent == null) {
810            return;
811        }
812        try {
813            // If the intent was created from a suggestion, it will always have an explicit
814            // component here.
815            getContext().startActivity(intent);
816        } catch (RuntimeException ex) {
817            Log.e(LOG_TAG, "Failed launch activity: " + intent, ex);
818        }
819    }
820
821    /**
822     * Sets the text in the query box, without updating the suggestions.
823     */
824    private void setQuery(CharSequence query) {
825        mQueryTextView.setText(query, true);
826        // Move the cursor to the end
827        mQueryTextView.setSelection(TextUtils.isEmpty(query) ? 0 : query.length());
828    }
829
830    private void launchQuerySearch(int actionKey, String actionMsg, String query) {
831        String action = Intent.ACTION_SEARCH;
832        Intent intent = createIntent(action, null, null, query, actionKey, actionMsg);
833        getContext().startActivity(intent);
834    }
835
836    /**
837     * Constructs an intent from the given information and the search dialog state.
838     *
839     * @param action Intent action.
840     * @param data Intent data, or <code>null</code>.
841     * @param extraData Data for {@link SearchManager#EXTRA_DATA_KEY} or <code>null</code>.
842     * @param query Intent query, or <code>null</code>.
843     * @param actionKey The key code of the action key that was pressed,
844     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
845     * @param actionMsg The message for the action key that was pressed,
846     *        or <code>null</code> if none.
847     * @param mode The search mode, one of the acceptable values for
848     *             {@link SearchManager#SEARCH_MODE}, or {@code null}.
849     * @return The intent.
850     */
851    private Intent createIntent(String action, Uri data, String extraData, String query,
852            int actionKey, String actionMsg) {
853        // Now build the Intent
854        Intent intent = new Intent(action);
855        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
856        // We need CLEAR_TOP to avoid reusing an old task that has other activities
857        // on top of the one we want. We don't want to do this in in-app search though,
858        // as it can be destructive to the activity stack.
859        if (data != null) {
860            intent.setData(data);
861        }
862        intent.putExtra(SearchManager.USER_QUERY, query);
863        if (query != null) {
864            intent.putExtra(SearchManager.QUERY, query);
865        }
866        if (extraData != null) {
867            intent.putExtra(SearchManager.EXTRA_DATA_KEY, extraData);
868        }
869        if (actionKey != KeyEvent.KEYCODE_UNKNOWN) {
870            intent.putExtra(SearchManager.ACTION_KEY, actionKey);
871            intent.putExtra(SearchManager.ACTION_MSG, actionMsg);
872        }
873        intent.setComponent(mSearchable.getSearchActivity());
874        return intent;
875    }
876
877    /**
878     * Create and return an Intent that can launch the voice search activity for web search.
879     */
880    private Intent createVoiceWebSearchIntent(Intent baseIntent, SearchableInfo searchable) {
881        Intent voiceIntent = new Intent(baseIntent);
882        ComponentName searchActivity = searchable.getSearchActivity();
883        voiceIntent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, searchActivity == null ? null
884                : searchActivity.flattenToShortString());
885        return voiceIntent;
886    }
887
888    /**
889     * Create and return an Intent that can launch the voice search activity, perform a specific
890     * voice transcription, and forward the results to the searchable activity.
891     *
892     * @param baseIntent The voice app search intent to start from
893     * @return A completely-configured intent ready to send to the voice search activity
894     */
895    private Intent createVoiceAppSearchIntent(Intent baseIntent, SearchableInfo searchable) {
896        ComponentName searchActivity = searchable.getSearchActivity();
897
898        // create the necessary intent to set up a search-and-forward operation
899        // in the voice search system.   We have to keep the bundle separate,
900        // because it becomes immutable once it enters the PendingIntent
901        Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
902        queryIntent.setComponent(searchActivity);
903        PendingIntent pending = PendingIntent.getActivity(getContext(), 0, queryIntent,
904                PendingIntent.FLAG_ONE_SHOT);
905
906        // Now set up the bundle that will be inserted into the pending intent
907        // when it's time to do the search.  We always build it here (even if empty)
908        // because the voice search activity will always need to insert "QUERY" into
909        // it anyway.
910        Bundle queryExtras = new Bundle();
911
912        // Now build the intent to launch the voice search.  Add all necessary
913        // extras to launch the voice recognizer, and then all the necessary extras
914        // to forward the results to the searchable activity
915        Intent voiceIntent = new Intent(baseIntent);
916
917        // Add all of the configuration options supplied by the searchable's metadata
918        String languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
919        String prompt = null;
920        String language = null;
921        int maxResults = 1;
922
923        Resources resources = getResources();
924        if (searchable.getVoiceLanguageModeId() != 0) {
925            languageModel = resources.getString(searchable.getVoiceLanguageModeId());
926        }
927        if (searchable.getVoicePromptTextId() != 0) {
928            prompt = resources.getString(searchable.getVoicePromptTextId());
929        }
930        if (searchable.getVoiceLanguageId() != 0) {
931            language = resources.getString(searchable.getVoiceLanguageId());
932        }
933        if (searchable.getVoiceMaxResults() != 0) {
934            maxResults = searchable.getVoiceMaxResults();
935        }
936        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
937        voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
938        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
939        voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
940        voiceIntent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, searchActivity == null ? null
941                : searchActivity.flattenToShortString());
942
943        // Add the values that configure forwarding the results
944        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
945        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
946
947        return voiceIntent;
948    }
949
950    /**
951     * When a particular suggestion has been selected, perform the various lookups required
952     * to use the suggestion.  This includes checking the cursor for suggestion-specific data,
953     * and/or falling back to the XML for defaults;  It also creates REST style Uri data when
954     * the suggestion includes a data id.
955     *
956     * @param c The suggestions cursor, moved to the row of the user's selection
957     * @param actionKey The key code of the action key that was pressed,
958     *        or {@link KeyEvent#KEYCODE_UNKNOWN} if none.
959     * @param actionMsg The message for the action key that was pressed,
960     *        or <code>null</code> if none.
961     * @return An intent for the suggestion at the cursor's position.
962     */
963    private Intent createIntentFromSuggestion(Cursor c, int actionKey, String actionMsg) {
964        try {
965            // use specific action if supplied, or default action if supplied, or fixed default
966            String action = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_ACTION);
967
968            if (action == null) {
969                action = mSearchable.getSuggestIntentAction();
970            }
971            if (action == null) {
972                action = Intent.ACTION_SEARCH;
973            }
974
975            // use specific data if supplied, or default data if supplied
976            String data = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
977            if (data == null) {
978                data = mSearchable.getSuggestIntentData();
979            }
980            // then, if an ID was provided, append it.
981            if (data != null) {
982                String id = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
983                if (id != null) {
984                    data = data + "/" + Uri.encode(id);
985                }
986            }
987            Uri dataUri = (data == null) ? null : Uri.parse(data);
988
989            String query = getColumnString(c, SearchManager.SUGGEST_COLUMN_QUERY);
990            String extraData = getColumnString(c, SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA);
991
992            return createIntent(action, dataUri, extraData, query, actionKey, actionMsg);
993        } catch (RuntimeException e ) {
994            int rowNum;
995            try {                       // be really paranoid now
996                rowNum = c.getPosition();
997            } catch (RuntimeException e2 ) {
998                rowNum = -1;
999            }
1000            Log.w(LOG_TAG, "Search Suggestions cursor at row " + rowNum +
1001                            " returned exception" + e.toString());
1002            return null;
1003        }
1004    }
1005
1006    /**
1007     * Callback to watch the text field for empty/non-empty
1008     */
1009    private TextWatcher mTextWatcher = new TextWatcher() {
1010
1011        public void beforeTextChanged(CharSequence s, int start, int before, int after) { }
1012
1013        public void onTextChanged(CharSequence s, int start,
1014                int before, int after) {
1015            SearchView.this.onTextChanged(s);
1016        }
1017
1018        public void afterTextChanged(Editable s) {
1019        }
1020    };
1021}
1022