SearchActivity.java revision ac17ac866d6f543c3a136e1e2361c01004c6547e
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    }
180
181    private Source getSourceByName(String sourceNameStr) {
182        if (sourceNameStr == null) return null;
183        ComponentName sourceName = ComponentName.unflattenFromString(sourceNameStr);
184        if (sourceName == null) {
185            Log.w(TAG, "Malformed source name: " + sourceName);
186            return null;
187        }
188        return getSourceByComponentName(sourceName);
189    }
190
191    private Source getSourceByComponentName(ComponentName sourceName) {
192        Source source = getSources().getSourceByComponentName(sourceName);
193        if (source == null) {
194            Log.w(TAG, "Unknown source " + sourceName);
195            return null;
196        }
197        return source;
198    }
199
200    private void setSource(Source source) {
201        if (DBG) Log.d(TAG, "setSource(" + source + ")");
202        mSource = source;
203        Drawable sourceIcon;
204        if (source == null) {
205            sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon();
206        } else {
207            sourceIcon = source.getSourceIcon();
208        }
209        ComponentName sourceName = getSourceName();
210        mSuggestionsAdapter.setSource(sourceName);
211        mSourceSelector.setSource(sourceName);
212        mSourceSelector.setSourceIcon(sourceIcon);
213    }
214
215    private ComponentName getSourceName() {
216        return mSource == null ? null : mSource.getComponentName();
217    }
218
219    private QsbApplication getQsbApplication() {
220        return (QsbApplication) getApplication();
221    }
222
223    private SourceLookup getSources() {
224        return getQsbApplication().getSources();
225    }
226
227    private ShortcutRepository getShortcutRepository() {
228        return getQsbApplication().getShortcutRepository();
229    }
230
231    private SuggestionsProvider getSuggestionsProvider(Source source) {
232        return getQsbApplication().getSuggestionsProvider(source);
233    }
234
235    private SuggestionViewFactory getSuggestionViewFactory() {
236        return getQsbApplication().getSuggestionViewFactory();
237    }
238
239    @Override
240    protected void onDestroy() {
241        if (DBG) Log.d(TAG, "onDestroy()");
242        super.onDestroy();
243        mSuggestionsView.setAdapter(null);  // closes mSuggestionsAdapter
244    }
245
246    @Override
247    protected void onStop() {
248        if (DBG) Log.d(TAG, "onStop()");
249        // Close all open suggestion cursors. The query will be redone in onResume()
250        // if we come back to this activity.
251        mSuggestionsAdapter.setSuggestions(null);
252        getQsbApplication().getShortcutRefresher().reset();
253        super.onStop();
254    }
255
256    @Override
257    protected void onResume() {
258        if (DBG) Log.d(TAG, "onResume()");
259        super.onResume();
260        setQuery(mUserQuery, mSelectAll);
261        // Only select everything the first time after creating the activity.
262        mSelectAll = false;
263        updateSuggestions(mUserQuery);
264        mQueryTextView.requestFocus();
265    }
266
267    @Override
268    public boolean onCreateOptionsMenu(Menu menu) {
269        super.onCreateOptionsMenu(menu);
270
271        Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS);
272        settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
273        // Don't show activity chooser if there are multiple search settings activities,
274        // e.g. from different QSB implementations.
275        settings.setPackage(this.getPackageName());
276        menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings)
277                .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P')
278                .setIntent(settings);
279
280        return true;
281    }
282
283    /**
284     * Sets the query as typed by the user. Does not update the suggestions
285     * or the text in the query box.
286     */
287    protected void setUserQuery(String userQuery) {
288        if (userQuery == null) userQuery = "";
289        mUserQuery = userQuery;
290        mSourceSelector.setQuery(mUserQuery);
291    }
292
293    protected void setAppSearchData(Bundle appSearchData) {
294        mLauncher.setAppSearchData(appSearchData);
295        mSourceSelector.setAppSearchData(appSearchData);
296    }
297
298    protected String getQuery() {
299        CharSequence q = mQueryTextView.getText();
300        return q == null ? "" : q.toString();
301    }
302
303    /**
304     * Restores the query entered by the user.
305     */
306    private void restoreUserQuery() {
307        if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'");
308        setQuery(mUserQuery, false);
309    }
310
311    /**
312     * Sets the text in the query box. Does not update the suggestions,
313     * and does not change the saved user-entered query.
314     * {@link #restoreUserQuery()} will restore the query to the last
315     * user-entered query.
316     */
317    private void setQuery(String query, boolean selectAll) {
318        mUpdateSuggestions = false;
319        mQueryTextView.setText(query);
320        setTextSelection(selectAll);
321        mUpdateSuggestions = true;
322    }
323
324    /**
325     * Sets the text selection in the query text view.
326     *
327     * @param selectAll If {@code true}, selects the entire query.
328     *        If {@false}, no characters are selected, and the cursor is placed
329     *        at the end of the query.
330     */
331    private void setTextSelection(boolean selectAll) {
332        if (selectAll) {
333            mQueryTextView.setSelection(0, mQueryTextView.length());
334        } else {
335            mQueryTextView.setSelection(mQueryTextView.length());
336        }
337    }
338
339    protected void onSearchClicked() {
340        String query = getQuery();
341        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
342        mLauncher.startSearch(mSource, query);
343    }
344
345    protected void onVoiceSearchClicked() {
346        if (DBG) Log.d(TAG, "Voice Search clicked");
347        // TODO: should this start voice search in the current source?
348        mLauncher.startVoiceSearch();
349    }
350
351    protected boolean launchSuggestion(SuggestionPosition suggestion) {
352        return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null);
353    }
354
355    protected boolean launchSuggestion(SuggestionPosition suggestion,
356            int actionKey, String actionMsg) {
357        if (DBG) Log.d(TAG, "Launching suggestion " + suggestion);
358        mLauncher.launchSuggestion(suggestion, actionKey, actionMsg);
359        getShortcutRepository().reportClick(suggestion);
360        return true;
361    }
362
363    protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
364        SuggestionCursor sourceResult = suggestion.getSuggestion();
365        if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1());
366        return false;
367    }
368
369    protected void onSuggestionSelected(SuggestionPosition suggestion) {
370        if (suggestion == null) {
371            // This happens when a suggestion has been selected with the
372            // dpad / trackball and then a different UI element is touched.
373            // Do nothing, since we want to keep the query of the selection
374            // in the search box.
375            return;
376        }
377        SuggestionCursor sourceResult = suggestion.getSuggestion();
378        String displayQuery = sourceResult.getSuggestionDisplayQuery();
379        if (DBG) {
380            Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1()
381                    + ",displayQuery="+ displayQuery);
382        }
383        if (TextUtils.isEmpty(displayQuery)) {
384            restoreUserQuery();
385        } else {
386            setQuery(displayQuery, false);
387        }
388    }
389
390    protected boolean onSuggestionKeyDown(SuggestionPosition suggestion,
391            int keyCode, KeyEvent event) {
392        // Treat enter or search as a click
393        if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) {
394            return launchSuggestion(suggestion);
395        }
396
397        if (keyCode == KeyEvent.KEYCODE_DPAD_UP
398                && mSuggestionsView.getSelectedItemPosition() == 0) {
399            // Moved up from the top suggestion, restore the user query and focus query box
400            if (DBG) Log.d(TAG, "Up and out");
401            restoreUserQuery();
402            return false;  // let the framework handle the move
403        }
404
405        if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
406                || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
407            // Moved left / right from a suggestion, keep current query, move
408            // focus to query box, and move cursor to far left / right
409            if (DBG) Log.d(TAG, "Left/right on a suggestion");
410            int cursorPos = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? 0 : mQueryTextView.length();
411            mQueryTextView.setSelection(cursorPos);
412            mQueryTextView.requestFocus();
413            // TODO: should we modify the list selection?
414            return true;
415        }
416
417        // Handle source-specified action keys
418        String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode);
419        if (actionMsg != null) {
420            return launchSuggestion(suggestion, keyCode, actionMsg);
421        }
422
423        return false;
424    }
425
426    protected void onSourceSelected() {
427        if (DBG) Log.d(TAG, "No suggestion selected");
428        restoreUserQuery();
429    }
430
431    protected int getSelectedPosition() {
432        return mSuggestionsView.getSelectedPosition();
433    }
434
435    protected SuggestionPosition getSelectedSuggestion() {
436        return mSuggestionsView.getSelectedSuggestion();
437    }
438
439    /**
440     * Hides the input method.
441     */
442    protected void hideInputMethod() {
443        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
444        if (imm != null) {
445            imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0);
446        }
447    }
448
449    protected void showInputMethodForQuery() {
450        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
451        if (imm != null) {
452            imm.showSoftInput(mQueryTextView, 0);
453        }
454    }
455
456    /**
457     * Hides the input method when the suggestions get focus.
458     */
459    private class SuggestListFocusListener implements OnFocusChangeListener {
460        public void onFocusChange(View v, boolean focused) {
461            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
462            if (focused) {
463                // The suggestions list got focus, hide the input method
464                hideInputMethod();
465            }
466        }
467    }
468
469    private class QueryTextViewFocusListener implements OnFocusChangeListener {
470        public void onFocusChange(View v, boolean focused) {
471            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
472            if (focused) {
473                // The query box got focus, show the input method if the
474                // query box got focus?
475                showInputMethodForQuery();
476            }
477        }
478    }
479
480    private void startSearchProgress() {
481        // TODO: Cache animation between calls?
482        mSearchGoButton.setImageResource(R.drawable.searching);
483        Animatable animation = (Animatable) mSearchGoButton.getDrawable();
484        animation.start();
485    }
486
487    private void stopSearchProgress() {
488        Drawable animation = mSearchGoButton.getDrawable();
489        if (animation instanceof Animatable) {
490            // TODO: Is this needed, or is it done automatically when the
491            // animation is removed?
492            ((Animatable) animation).stop();
493        }
494        mSearchGoButton.setImageResource(R.drawable.ic_btn_search);
495    }
496
497    private void updateSuggestions(String query) {
498        LatencyTracker latency = new LatencyTracker(TAG);
499        Suggestions suggestions = getSuggestionsProvider(mSource).getSuggestions(query);
500        latency.addEvent("getSuggestions_done");
501        if (!suggestions.isDone()) {
502            suggestions.registerDataSetObserver(new ProgressUpdater(suggestions));
503            startSearchProgress();
504        } else {
505            stopSearchProgress();
506        }
507        mSuggestionsAdapter.setSuggestions(suggestions);
508        latency.addEvent("shortcuts_shown");
509        long userVisibleLatency = latency.getUserVisibleLatency();
510        if (DBG) {
511            Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms.");
512        }
513    }
514
515    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
516        if (!event.isSystem() && !isDpadKey(keyCode)) {
517            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
518            if (mQueryTextView.requestFocus()) {
519                return mQueryTextView.dispatchKeyEvent(event);
520            }
521        }
522        return false;
523    }
524
525    private boolean isDpadKey(int keyCode) {
526        switch (keyCode) {
527            case KeyEvent.KEYCODE_DPAD_UP:
528            case KeyEvent.KEYCODE_DPAD_DOWN:
529            case KeyEvent.KEYCODE_DPAD_LEFT:
530            case KeyEvent.KEYCODE_DPAD_RIGHT:
531            case KeyEvent.KEYCODE_DPAD_CENTER:
532                return true;
533            default:
534                return false;
535        }
536    }
537
538    /**
539     * Filters the suggestions list when the search text changes.
540     */
541    private class SearchTextWatcher implements TextWatcher {
542        public void afterTextChanged(Editable s) {
543            if (mUpdateSuggestions) {
544                String query = s == null ? "" : s.toString();
545                setUserQuery(query);
546                updateSuggestions(query);
547            }
548        }
549
550        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
551        }
552
553        public void onTextChanged(CharSequence s, int start, int before, int count) {
554        }
555    }
556
557    /**
558     * Handles non-text keys in the query text view.
559     */
560    private class QueryTextViewKeyListener implements View.OnKeyListener {
561        public boolean onKey(View view, int keyCode, KeyEvent event) {
562            // Handle IME search action key
563            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
564                onSearchClicked();
565            }
566            return false;
567        }
568    }
569
570    /**
571     * Handles key events on the search and voice search buttons,
572     * by refocusing to EditText.
573     */
574    private class ButtonsKeyListener implements View.OnKeyListener {
575        public boolean onKey(View v, int keyCode, KeyEvent event) {
576            return forwardKeyToQueryTextView(keyCode, event);
577        }
578    }
579
580    /**
581     * Handles key events on the suggestions list view.
582     */
583    private class SuggestionsViewKeyListener implements View.OnKeyListener {
584        public boolean onKey(View v, int keyCode, KeyEvent event) {
585            if (event.getAction() == KeyEvent.ACTION_DOWN) {
586                SuggestionPosition suggestion = getSelectedSuggestion();
587                if (suggestion != null) {
588                    if (onSuggestionKeyDown(suggestion, keyCode, event)) {
589                        return true;
590                    }
591                }
592            }
593            return forwardKeyToQueryTextView(keyCode, event);
594        }
595    }
596
597    private class InputMethodCloser implements SuggestionsView.InteractionListener {
598        public void onInteraction() {
599            hideInputMethod();
600        }
601    }
602
603    private class ClickHandler implements SuggestionClickListener {
604       public void onSuggestionClicked(SuggestionPosition suggestion) {
605           launchSuggestion(suggestion);
606       }
607
608       public boolean onSuggestionLongClicked(SuggestionPosition suggestion) {
609           return SearchActivity.this.onSuggestionLongClicked(suggestion);
610       }
611    }
612
613    private class SelectionHandler implements SuggestionSelectionListener {
614        public void onSelectionChanged(SuggestionPosition suggestion) {
615            onSuggestionSelected(suggestion);
616        }
617    }
618
619    /**
620     * Listens for clicks on the search button.
621     */
622    private class SearchGoButtonClickListener implements View.OnClickListener {
623        public void onClick(View view) {
624            onSearchClicked();
625        }
626    }
627
628    /**
629     * Listens for clicks on the voice search button.
630     */
631    private class VoiceSearchButtonClickListener implements View.OnClickListener {
632        public void onClick(View view) {
633            onVoiceSearchClicked();
634        }
635    }
636
637    /**
638     * Updates the progress bar when the suggestions adapter changes its progress.
639     */
640    private class ProgressUpdater extends DataSetObserver {
641        private final Suggestions mSuggestions;
642
643        public ProgressUpdater(Suggestions suggestions) {
644            mSuggestions = suggestions;
645        }
646
647        @Override
648        public void onChanged() {
649            if (mSuggestions.isDone()) {
650                stopSearchProgress();
651            }
652        }
653    }
654
655}
656