SearchActivity.java revision 94e8a2be78530170f50e7895a558bf8011bbf8e8
1/*
2 * Copyright (C) 2009 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 com.android.quicksearchbox;
18
19import com.android.quicksearchbox.ui.SearchSourceSelector;
20import com.android.quicksearchbox.ui.SuggestionClickListener;
21import com.android.quicksearchbox.ui.SuggestionSelectionListener;
22import com.android.quicksearchbox.ui.SuggestionViewFactory;
23import com.android.quicksearchbox.ui.SuggestionsAdapter;
24import com.android.quicksearchbox.ui.SuggestionsView;
25
26import android.app.Activity;
27import android.app.SearchManager;
28import android.content.ComponentName;
29import android.content.Intent;
30import android.database.DataSetObserver;
31import android.graphics.Rect;
32import android.graphics.drawable.Animatable;
33import android.graphics.drawable.Drawable;
34import android.os.Bundle;
35import android.text.Editable;
36import android.text.TextUtils;
37import android.text.TextWatcher;
38import android.util.Log;
39import android.view.KeyEvent;
40import android.view.Menu;
41import android.view.View;
42import android.view.View.OnFocusChangeListener;
43import android.view.inputmethod.InputMethodManager;
44import android.widget.EditText;
45import android.widget.ImageButton;
46
47/**
48 * The main activity for Quick Search Box. Shows the search UI.
49 *
50 */
51public class SearchActivity extends Activity {
52
53    private static final boolean DBG = true;
54    private static final String TAG = "QSB.SearchActivity";
55
56    // TODO: This is hidden in SearchManager
57    public final static String INTENT_ACTION_SEARCH_SETTINGS
58            = "android.search.action.SEARCH_SETTINGS";
59
60    public static final String EXTRA_KEY_SEARCH_SOURCE
61            = "search_source";
62
63    // Keys for the saved instance state.
64    private static final String INSTANCE_KEY_SOURCE = "source";
65    private static final String INSTANCE_KEY_USER_QUERY = "query";
66
67    protected SuggestionsAdapter mSuggestionsAdapter;
68
69    protected EditText mQueryTextView;
70
71    protected SuggestionsView mSuggestionsView;
72
73    protected ImageButton mSearchGoButton;
74    protected ImageButton mVoiceSearchButton;
75    protected SearchSourceSelector mSourceSelector;
76
77    private Launcher mLauncher;
78
79    private Source mSource;
80    private boolean mUpdateSuggestions;
81    private String mUserQuery;
82    private boolean mSelectAll;
83
84    /** Called when the activity is first created. */
85    @Override
86    public void onCreate(Bundle savedInstanceState) {
87        if (DBG) Log.d(TAG, "onCreate()");
88        super.onCreate(savedInstanceState);
89
90        setContentView(R.layout.search_bar);
91
92        mSuggestionsAdapter = getQsbApplication().createSuggestionsAdapter();
93
94        mQueryTextView = (EditText) findViewById(R.id.search_src_text);
95        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
96        mSuggestionsView.setSuggestionClickListener(new ClickHandler());
97        mSuggestionsView.setSuggestionSelectionListener(new SelectionHandler());
98        mSuggestionsView.setInteractionListener(new InputMethodCloser());
99        mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
100        mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
101
102        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
103        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
104        mSourceSelector = new SearchSourceSelector(findViewById(R.id.search_source_selector));
105
106        mLauncher = new Launcher(this);
107        // TODO: should this check for voice search in the current source?
108        mVoiceSearchButton.setVisibility(
109                mLauncher.isVoiceSearchAvailable() ? View.VISIBLE : View.GONE);
110
111        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
112        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
113        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
114
115        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
116
117        mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener());
118
119        ButtonsKeyListener buttonsKeyListener = new ButtonsKeyListener();
120        mSearchGoButton.setOnKeyListener(buttonsKeyListener);
121        mVoiceSearchButton.setOnKeyListener(buttonsKeyListener);
122        mSourceSelector.setOnKeyListener(buttonsKeyListener);
123
124        mUpdateSuggestions = true;
125
126        // First get setup from intent
127        Intent intent = getIntent();
128        setupFromIntent(intent);
129        // Then restore any saved instance state
130        restoreInstanceState(savedInstanceState);
131
132        // Do this at the end, to avoid updating the list view when setSource()
133        // is called.
134        mSuggestionsView.setAdapter(mSuggestionsAdapter);
135    }
136
137    @Override
138    protected void onNewIntent(Intent intent) {
139        setIntent(intent);
140        setupFromIntent(intent);
141    }
142
143    protected void restoreInstanceState(Bundle savedInstanceState) {
144        if (savedInstanceState == null) return;
145        ComponentName sourceName = savedInstanceState.getParcelable(INSTANCE_KEY_SOURCE);
146        String query = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY);
147        setSource(getSourceByComponentName(sourceName));
148        setUserQuery(query);
149    }
150
151    @Override
152    protected void onSaveInstanceState(Bundle outState) {
153        super.onSaveInstanceState(outState);
154        // We don't save appSearchData, since we always get the value
155        // from the intent and the user can't change it.
156        outState.putParcelable(INSTANCE_KEY_SOURCE, getSourceName());
157        outState.putString(INSTANCE_KEY_USER_QUERY, mUserQuery);
158    }
159
160    private void setupFromIntent(Intent intent) {
161        if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")");
162        if (intent.hasExtra(EXTRA_KEY_SEARCH_SOURCE)) {
163            Source source = getSourceByName(intent.getStringExtra(EXTRA_KEY_SEARCH_SOURCE));
164            setSource(source);
165            // The source was selected by the user, save it.
166            setLastSelectedSource(source);
167        } else {
168            Source source = getSources().getLastSelectedSource();
169            if (DBG) Log.d(TAG, "Setting source from preferences: " + source);
170            setSource(source);
171        }
172        setUserQuery(intent.getStringExtra(SearchManager.QUERY));
173        mSelectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false);
174        setAppSearchData(intent.getBundleExtra(SearchManager.APP_DATA));
175    }
176
177    private void setLastSelectedSource(Source source) {
178        getSources().setLastSelectedSource(source);
179        // Update search widgets to show the new source.
180        SearchWidgetProvider.updateSearchWidgets(this);
181    }
182
183    private Source getSourceByName(String sourceNameStr) {
184        if (sourceNameStr == null) return null;
185        ComponentName sourceName = ComponentName.unflattenFromString(sourceNameStr);
186        if (sourceName == null) {
187            Log.w(TAG, "Malformed source name: " + sourceName);
188            return null;
189        }
190        return getSourceByComponentName(sourceName);
191    }
192
193    private Source getSourceByComponentName(ComponentName sourceName) {
194        Source source = getSources().getSourceByComponentName(sourceName);
195        if (source == null) {
196            Log.w(TAG, "Unknown source " + sourceName);
197            return null;
198        }
199        return source;
200    }
201
202    private void setSource(Source source) {
203        if (DBG) Log.d(TAG, "setSource(" + source + ")");
204        mSource = source;
205        Drawable sourceIcon;
206        if (source == null) {
207            sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon();
208        } else {
209            sourceIcon = source.getSourceIcon();
210        }
211        ComponentName sourceName = getSourceName();
212        mSuggestionsAdapter.setSource(sourceName);
213        mSourceSelector.setSource(sourceName);
214        mSourceSelector.setSourceIcon(sourceIcon);
215    }
216
217    private ComponentName getSourceName() {
218        return mSource == null ? null : mSource.getComponentName();
219    }
220
221    private QsbApplication getQsbApplication() {
222        return (QsbApplication) getApplication();
223    }
224
225    private SourceLookup getSources() {
226        return getQsbApplication().getSources();
227    }
228
229    private ShortcutRepository getShortcutRepository() {
230        return getQsbApplication().getShortcutRepository();
231    }
232
233    private SuggestionsProvider getSuggestionsProvider(Source source) {
234        return getQsbApplication().getSuggestionsProvider(source);
235    }
236
237    private SuggestionViewFactory getSuggestionViewFactory() {
238        return getQsbApplication().getSuggestionViewFactory();
239    }
240
241    @Override
242    protected void onDestroy() {
243        if (DBG) Log.d(TAG, "onDestroy()");
244        super.onDestroy();
245        mSuggestionsView.setAdapter(null);  // closes mSuggestionsAdapter
246    }
247
248    @Override
249    protected void onStop() {
250        if (DBG) Log.d(TAG, "onStop()");
251        // Close all open suggestion cursors. The query will be redone in onResume()
252        // if we come back to this activity.
253        mSuggestionsAdapter.setSuggestions(null);
254        getQsbApplication().getShortcutRefresher().reset();
255        super.onStop();
256    }
257
258    @Override
259    protected void onResume() {
260        if (DBG) Log.d(TAG, "onResume()");
261        super.onResume();
262        setQuery(mUserQuery, mSelectAll);
263        // Only select everything the first time after creating the activity.
264        mSelectAll = false;
265        updateSuggestions(mUserQuery);
266        mQueryTextView.requestFocus();
267    }
268
269    @Override
270    public boolean onCreateOptionsMenu(Menu menu) {
271        super.onCreateOptionsMenu(menu);
272
273        Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS);
274        settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
275        // Don't show activity chooser if there are multiple search settings activities,
276        // e.g. from different QSB implementations.
277        settings.setPackage(this.getPackageName());
278        menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings)
279                .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P')
280                .setIntent(settings);
281
282        return true;
283    }
284
285    /**
286     * Sets the query as typed by the user. Does not update the suggestions
287     * or the text in the query box.
288     */
289    protected void setUserQuery(String userQuery) {
290        if (userQuery == null) userQuery = "";
291        mUserQuery = userQuery;
292        mSourceSelector.setQuery(mUserQuery);
293    }
294
295    protected void setAppSearchData(Bundle appSearchData) {
296        mLauncher.setAppSearchData(appSearchData);
297        mSourceSelector.setAppSearchData(appSearchData);
298    }
299
300    protected String getQuery() {
301        CharSequence q = mQueryTextView.getText();
302        return q == null ? "" : q.toString();
303    }
304
305    /**
306     * Restores the query entered by the user.
307     */
308    private void restoreUserQuery() {
309        if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'");
310        setQuery(mUserQuery, false);
311    }
312
313    /**
314     * Sets the text in the query box. Does not update the suggestions,
315     * and does not change the saved user-entered query.
316     * {@link #restoreUserQuery()} will restore the query to the last
317     * user-entered query.
318     */
319    private void setQuery(String query, boolean selectAll) {
320        mUpdateSuggestions = false;
321        mQueryTextView.setText(query);
322        setTextSelection(selectAll);
323        mUpdateSuggestions = true;
324    }
325
326    /**
327     * Sets the text selection in the query text view.
328     *
329     * @param selectAll If {@code true}, selects the entire query.
330     *        If {@false}, no characters are selected, and the cursor is placed
331     *        at the end of the query.
332     */
333    private void setTextSelection(boolean selectAll) {
334        if (selectAll) {
335            mQueryTextView.setSelection(0, mQueryTextView.length());
336        } else {
337            mQueryTextView.setSelection(mQueryTextView.length());
338        }
339    }
340
341    protected void onSearchClicked() {
342        String query = getQuery();
343        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
344        mLauncher.startSearch(mSource, query);
345    }
346
347    protected void onVoiceSearchClicked() {
348        if (DBG) Log.d(TAG, "Voice Search clicked");
349        // TODO: should this start voice search in the current source?
350        mLauncher.startVoiceSearch();
351    }
352
353    protected boolean launchSuggestion(SuggestionPosition suggestion) {
354        return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null);
355    }
356
357    protected boolean launchSuggestion(SuggestionPosition suggestion,
358            int actionKey, String actionMsg) {
359        if (DBG) Log.d(TAG, "Launching suggestion " + suggestion);
360        mLauncher.launchSuggestion(suggestion, actionKey, actionMsg);
361        getShortcutRepository().reportClick(suggestion);
362        // Update search widgets, since the top shortcuts can have changed.
363        SearchWidgetProvider.updateSearchWidgets(this);
364        return true;
365    }
366
367    protected boolean launchSuggestionSecondary(SuggestionPosition suggestion, Rect target) {
368      if (DBG) Log.d(TAG, "Clicked on suggestion icon " + suggestion);
369      mLauncher.launchSuggestionSecondary(suggestion, target);
370      getShortcutRepository().reportClick(suggestion);
371      return true;
372    }
373
374    protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
375        SuggestionCursor sourceResult = suggestion.getSuggestion();
376        if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1());
377        return false;
378    }
379
380    protected void onSuggestionSelected(SuggestionPosition suggestion) {
381        if (suggestion == null) {
382            // This happens when a suggestion has been selected with the
383            // dpad / trackball and then a different UI element is touched.
384            // Do nothing, since we want to keep the query of the selection
385            // in the search box.
386            return;
387        }
388        SuggestionCursor sourceResult = suggestion.getSuggestion();
389        String displayQuery = sourceResult.getSuggestionDisplayQuery();
390        if (DBG) {
391            Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1()
392                    + ",displayQuery="+ displayQuery);
393        }
394        if (TextUtils.isEmpty(displayQuery)) {
395            restoreUserQuery();
396        } else {
397            setQuery(displayQuery, false);
398        }
399    }
400
401    protected boolean onSuggestionKeyDown(SuggestionPosition suggestion,
402            int keyCode, KeyEvent event) {
403        // Treat enter or search as a click
404        if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
405            return launchSuggestion(suggestion);
406        }
407
408        if (keyCode == KeyEvent.KEYCODE_DPAD_UP
409                && mSuggestionsView.getSelectedItemPosition() == 0) {
410            // Moved up from the top suggestion, restore the user query and focus query box
411            if (DBG) Log.d(TAG, "Up and out");
412            restoreUserQuery();
413            return false;  // let the framework handle the move
414        }
415
416        if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
417                || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
418            // Moved left / right from a suggestion, keep current query, move
419            // focus to query box, and move cursor to far left / right
420            if (DBG) Log.d(TAG, "Left/right on a suggestion");
421            int cursorPos = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? 0 : mQueryTextView.length();
422            mQueryTextView.setSelection(cursorPos);
423            mQueryTextView.requestFocus();
424            // TODO: should we modify the list selection?
425            return true;
426        }
427
428        // Handle source-specified action keys
429        String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode);
430        if (actionMsg != null) {
431            return launchSuggestion(suggestion, keyCode, actionMsg);
432        }
433
434        return false;
435    }
436
437    protected void onSourceSelected() {
438        if (DBG) Log.d(TAG, "No suggestion selected");
439        restoreUserQuery();
440    }
441
442    protected int getSelectedPosition() {
443        return mSuggestionsView.getSelectedPosition();
444    }
445
446    protected SuggestionPosition getSelectedSuggestion() {
447        return mSuggestionsView.getSelectedSuggestion();
448    }
449
450    /**
451     * Hides the input method.
452     */
453    protected void hideInputMethod() {
454        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
455        if (imm != null) {
456            imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0);
457        }
458    }
459
460    protected void showInputMethodForQuery() {
461        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
462        if (imm != null) {
463            imm.showSoftInput(mQueryTextView, 0);
464        }
465    }
466
467    /**
468     * Hides the input method when the suggestions get focus.
469     */
470    private class SuggestListFocusListener implements OnFocusChangeListener {
471        public void onFocusChange(View v, boolean focused) {
472            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
473            if (focused) {
474                // The suggestions list got focus, hide the input method
475                hideInputMethod();
476            }
477        }
478    }
479
480    private class QueryTextViewFocusListener implements OnFocusChangeListener {
481        public void onFocusChange(View v, boolean focused) {
482            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
483            if (focused) {
484                // The query box got focus, show the input method if the
485                // query box got focus?
486                showInputMethodForQuery();
487            }
488        }
489    }
490
491    private void startSearchProgress() {
492        // TODO: Cache animation between calls?
493        mSearchGoButton.setImageResource(R.drawable.searching);
494        Animatable animation = (Animatable) mSearchGoButton.getDrawable();
495        animation.start();
496    }
497
498    private void stopSearchProgress() {
499        Drawable animation = mSearchGoButton.getDrawable();
500        if (animation instanceof Animatable) {
501            // TODO: Is this needed, or is it done automatically when the
502            // animation is removed?
503            ((Animatable) animation).stop();
504        }
505        mSearchGoButton.setImageResource(R.drawable.ic_btn_search);
506    }
507
508    private void updateSuggestions(String query) {
509        LatencyTracker latency = new LatencyTracker(TAG);
510        Suggestions suggestions = getSuggestionsProvider(mSource).getSuggestions(query);
511        latency.addEvent("getSuggestions_done");
512        if (!suggestions.isDone()) {
513            suggestions.registerDataSetObserver(new ProgressUpdater(suggestions));
514            startSearchProgress();
515        } else {
516            stopSearchProgress();
517        }
518        mSuggestionsAdapter.setSuggestions(suggestions);
519        latency.addEvent("shortcuts_shown");
520        long userVisibleLatency = latency.getUserVisibleLatency();
521        if (DBG) {
522            Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms.");
523        }
524    }
525
526    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
527        if (!event.isSystem() && !isDpadKey(keyCode)) {
528            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
529            if (mQueryTextView.requestFocus()) {
530                return mQueryTextView.dispatchKeyEvent(event);
531            }
532        }
533        return false;
534    }
535
536    private boolean isDpadKey(int keyCode) {
537        switch (keyCode) {
538            case KeyEvent.KEYCODE_DPAD_UP:
539            case KeyEvent.KEYCODE_DPAD_DOWN:
540            case KeyEvent.KEYCODE_DPAD_LEFT:
541            case KeyEvent.KEYCODE_DPAD_RIGHT:
542            case KeyEvent.KEYCODE_DPAD_CENTER:
543                return true;
544            default:
545                return false;
546        }
547    }
548
549    /**
550     * Filters the suggestions list when the search text changes.
551     */
552    private class SearchTextWatcher implements TextWatcher {
553        public void afterTextChanged(Editable s) {
554            if (mUpdateSuggestions) {
555                String query = s == null ? "" : s.toString();
556                setUserQuery(query);
557                updateSuggestions(query);
558            }
559        }
560
561        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
562        }
563
564        public void onTextChanged(CharSequence s, int start, int before, int count) {
565        }
566    }
567
568    /**
569     * Handles non-text keys in the query text view.
570     */
571    private class QueryTextViewKeyListener implements View.OnKeyListener {
572        public boolean onKey(View view, int keyCode, KeyEvent event) {
573            // Handle IME search action key
574            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
575                onSearchClicked();
576            }
577            return false;
578        }
579    }
580
581    /**
582     * Handles key events on the search and voice search buttons,
583     * by refocusing to EditText.
584     */
585    private class ButtonsKeyListener implements View.OnKeyListener {
586        public boolean onKey(View v, int keyCode, KeyEvent event) {
587            return forwardKeyToQueryTextView(keyCode, event);
588        }
589    }
590
591    /**
592     * Handles key events on the suggestions list view.
593     */
594    private class SuggestionsViewKeyListener implements View.OnKeyListener {
595        public boolean onKey(View v, int keyCode, KeyEvent event) {
596            if (event.getAction() == KeyEvent.ACTION_DOWN) {
597                SuggestionPosition suggestion = getSelectedSuggestion();
598                if (suggestion != null) {
599                    if (onSuggestionKeyDown(suggestion, keyCode, event)) {
600                        return true;
601                    }
602                }
603            }
604            return forwardKeyToQueryTextView(keyCode, event);
605        }
606    }
607
608    private class InputMethodCloser implements SuggestionsView.InteractionListener {
609        public void onInteraction() {
610            hideInputMethod();
611        }
612    }
613
614    private class ClickHandler implements SuggestionClickListener {
615       public void onSuggestionClicked(SuggestionPosition suggestion) {
616           launchSuggestion(suggestion);
617       }
618
619       public boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
620           return SearchActivity.this.onSuggestionLongClicked(suggestion);
621       }
622
623       public void onSuggestionIconClicked(SuggestionPosition suggestion, Rect rect) {
624           launchSuggestionSecondary(suggestion, rect);
625       }
626    }
627
628    private class SelectionHandler implements SuggestionSelectionListener {
629        public void onSelectionChanged(SuggestionPosition suggestion) {
630            onSuggestionSelected(suggestion);
631        }
632    }
633
634    /**
635     * Listens for clicks on the search button.
636     */
637    private class SearchGoButtonClickListener implements View.OnClickListener {
638        public void onClick(View view) {
639            onSearchClicked();
640        }
641    }
642
643    /**
644     * Listens for clicks on the voice search button.
645     */
646    private class VoiceSearchButtonClickListener implements View.OnClickListener {
647        public void onClick(View view) {
648            onVoiceSearchClicked();
649        }
650    }
651
652    /**
653     * Updates the progress bar when the suggestions adapter changes its progress.
654     */
655    private class ProgressUpdater extends DataSetObserver {
656        private final Suggestions mSuggestions;
657
658        public ProgressUpdater(Suggestions suggestions) {
659            mSuggestions = suggestions;
660        }
661
662        @Override
663        public void onChanged() {
664            if (mSuggestions.isDone()) {
665                stopSearchProgress();
666            }
667        }
668    }
669
670}
671