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