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