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