SearchActivity.java revision 185bb2e3881452c084fde44d9bee657f65881b0e
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.SuggestionClickListener;
20import com.android.quicksearchbox.ui.SuggestionViewFactory;
21import com.android.quicksearchbox.ui.SuggestionsAdapter;
22import com.android.quicksearchbox.ui.SuggestionsView;
23
24import android.app.Activity;
25import android.app.SearchManager;
26import android.content.Intent;
27import android.database.DataSetObserver;
28import android.graphics.Rect;
29import android.graphics.drawable.Animatable;
30import android.graphics.drawable.Drawable;
31import android.os.Bundle;
32import android.text.Editable;
33import android.text.TextUtils;
34import android.text.TextWatcher;
35import android.util.Log;
36import android.view.KeyEvent;
37import android.view.Menu;
38import android.view.View;
39import android.view.View.OnFocusChangeListener;
40import android.view.inputmethod.InputMethodManager;
41import android.widget.EditText;
42import android.widget.ImageButton;
43import android.widget.ProgressBar;
44import android.widget.ScrollView;
45
46import java.util.ArrayList;
47
48// TODO: close / deactivate cursors in onPause() or onStop()
49// TODO: use permission to get extended Genie suggestions
50// TODO: show spinner until done
51// TODO: don't show new results until there is at least one, or it's done
52// TODO: add timeout for source queries
53// TODO: group refreshes that happen close to each other.
54// TODO: handle long clicks
55// TODO: search / voice search when button pressed
56// TODO: use queryAfterZeroResults()
57// TODO: support action keys
58// TODO: support IME search action
59// TODO: allow typing everywhere in the UI
60// TODO: support intent extras for source (e.g. launcher widget)
61// TODO: support intent extras for initial state, e.g. query, selection
62// TODO: make Config server-side configurable
63// TODO: add resourced for hi-res version, and fix layouts
64// TODO: log impressions
65// TODO: use source ranking
66// TODO: Show tabs at bottom too
67
68/**
69 * The main activity for Quick Search Box. Shows the search UI.
70 *
71 */
72public class SearchActivity extends Activity {
73
74    private static final boolean DBG = true;
75    private static final String TAG = "SearchActivity";
76
77    // TODO: This is hidden in SearchManager
78    public final static String INTENT_ACTION_SEARCH_SETTINGS
79            = "android.search.action.SEARCH_SETTINGS";
80
81    protected SuggestionsAdapter mSuggestionsAdapter;
82
83    protected EditText mQueryTextView;
84
85    protected ScrollView mSuggestionsScrollView;
86    protected SuggestionsView mSuggestionsView;
87
88    protected ImageButton mSearchGoButton;
89    protected ImageButton mVoiceSearchButton;
90    protected ProgressBar mProgressBar;
91
92    private Launcher mLauncher;
93
94    private boolean mUpdateSuggestions = true;
95    private String mUserQuery = "";
96    private boolean mSelectAll = false;
97
98    /** Called when the activity is first created. */
99    @Override
100    public void onCreate(Bundle savedInstanceState) {
101        if (DBG) Log.d(TAG, "onCreate()");
102        // TODO: Use savedInstanceState to restore state
103        super.onCreate(savedInstanceState);
104        setContentView(R.layout.search_bar);
105
106        Config config = getConfig();
107        SuggestionViewFactory viewFactory = getSuggestionViewFactory();
108        mSuggestionsAdapter = new SuggestionsAdapter(viewFactory);
109        mSuggestionsAdapter
110                .setInitialSourceResultWaitMillis(config.getInitialSourceResultWaitMillis());
111        mSuggestionsAdapter
112                .setSourceResultPublishDelayMillis(config.getSourceResultPublishDelayMillis());
113        mSuggestionsAdapter.setSources(getSuggestionsProvider().getOrderedSources());
114
115        mQueryTextView = (EditText) findViewById(R.id.search_src_text);
116        mSuggestionsScrollView = (ScrollView) findViewById(R.id.suggestions_scroll);
117        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
118        mSuggestionsView.setAdapter(mSuggestionsAdapter);
119
120        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
121        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
122
123        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
124        mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener());
125        mQueryTextView.setOnFocusChangeListener(new SuggestListFocusListener());
126
127        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
128        mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener());
129
130        Bundle appSearchData = null;
131
132        Intent intent = getIntent();
133        // getIntent() currently always returns non-null, but the API does not guarantee
134        // that it always will.
135        if (intent != null) {
136            String initialQuery = intent.getStringExtra(SearchManager.QUERY);
137            if (!TextUtils.isEmpty(initialQuery)) {
138                mUserQuery = initialQuery;
139            }
140            // TODO: Declare an intent extra for selectAll
141            appSearchData = intent.getBundleExtra(SearchManager.APP_DATA);
142        }
143        mLauncher = new Launcher(this, appSearchData);
144        mVoiceSearchButton.setVisibility(
145                mLauncher.isVoiceSearchAvailable() ? View.VISIBLE : View.GONE);
146
147        mSuggestionsView.setSuggestionClickListener(new ClickHandler());
148        mSuggestionsView.setInteractionListener(new InputMethodCloser());
149    }
150
151    private QsbApplication getQsbApplication() {
152        return (QsbApplication) getApplication();
153    }
154
155    private Config getConfig() {
156        return getQsbApplication().getConfig();
157    }
158
159    private ShortcutRepository getShortcutRepository() {
160        return getQsbApplication().getShortcutRepository();
161    }
162
163    private SuggestionsProvider getSuggestionsProvider() {
164        return getQsbApplication().getSuggestionsProvider();
165    }
166
167    private SuggestionViewFactory getSuggestionViewFactory() {
168        return getQsbApplication().getSuggestionViewFactory();
169    }
170
171    @Override
172    protected void onDestroy() {
173        if (DBG) Log.d(TAG, "onDestroy()");
174        super.onDestroy();
175        mSuggestionsView.setAdapter(null);  // closes mSuggestionsAdapter
176    }
177
178    @Override
179    protected void onStart() {
180        if (DBG) Log.d(TAG, "onStart()");
181        super.onStart();
182        setQuery(mUserQuery, mSelectAll);
183        // Only select everything the first time after creating the activity.
184        mSelectAll = false;
185        updateSuggestions(mUserQuery);
186    }
187
188    @Override
189    protected void onStop() {
190        if (DBG) Log.d(TAG, "onResume()");
191        // Close all open suggestion cursors. The query will be redone in onStart()
192        // if we come back to this activity.
193        mSuggestionsAdapter.setSuggestions(null);
194        super.onStop();
195    }
196
197    @Override
198    protected void onResume() {
199        super.onResume();
200        mQueryTextView.requestFocus();
201    }
202
203    @Override
204    public boolean onCreateOptionsMenu(Menu menu) {
205        super.onCreateOptionsMenu(menu);
206
207        Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS);
208        settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
209        // Don't show activity chooser if there are multiple search settings activities,
210        // e.g. from different QSB implementations.
211        settings.setPackage(this.getPackageName());
212        menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings)
213                .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P')
214                .setIntent(settings);
215
216        return true;
217    }
218
219    private String getQuery() {
220        CharSequence q = mQueryTextView.getText();
221        return q == null ? "" : q.toString();
222    }
223
224    /**
225     * Restores the query entered by the user.
226     */
227    private void restoreUserQuery() {
228        if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'");
229        setQuery(mUserQuery, false);
230    }
231
232    /**
233     * Sets the text in the query box. Does not update the suggestions,
234     * and does not change the saved user-entered query.
235     * {@link #restoreUserQuery()} will restore the query to the last
236     * user-entered query.
237     */
238    private void setQuery(String query, boolean selectAll) {
239        mUpdateSuggestions = false;
240        mQueryTextView.setText(query);
241        setTextSelection(selectAll);
242        mUpdateSuggestions = true;
243    }
244
245    /**
246     * Sets the text selection in the query text view.
247     *
248     * @param selectAll If {@code true}, selects the entire query.
249     *        If {@false}, no characters are selected, and the cursor is placed
250     *        at the end of the query.
251     */
252    private void setTextSelection(boolean selectAll) {
253        if (selectAll) {
254            mQueryTextView.setSelection(0, mQueryTextView.length());
255        } else {
256            mQueryTextView.setSelection(mQueryTextView.length());
257        }
258    }
259
260    protected void onSearchClicked() {
261        String query = getQuery();
262        if (DBG) Log.d(TAG, "Search clicked, query=" + query);
263        mLauncher.startWebSearch(query);
264    }
265
266    protected void onVoiceSearchClicked() {
267        if (DBG) Log.d(TAG, "Voice Search clicked");
268        mLauncher.startVoiceSearch();
269    }
270
271    protected boolean onSuggestionClicked(SuggestionPosition suggestion) {
272        if (DBG) Log.d(TAG, "Clicked on suggestion " + suggestion);
273        // TODO: handle action keys
274        mLauncher.launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null);
275        getShortcutRepository().reportClick(suggestion);
276        // Update search widgets, since the top shortcuts can have changed.
277        SearchWidgetProvider.updateSearchWidgets(this);
278        return true;
279    }
280
281    protected boolean onIconClicked(SuggestionPosition suggestion, Rect target) {
282      if (DBG) Log.d(TAG, "Clicked on suggestion icon " + suggestion);
283      mLauncher.launchSuggestionSecondary(suggestion, target);
284      getShortcutRepository().reportClick(suggestion);
285      return true;
286    }
287
288    protected boolean onSuggestionLongClicked(SuggestionCursor sourceResult) {
289        if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1());
290        return false;
291    }
292
293    protected void onSuggestionSelected(SuggestionCursor sourceResult) {
294        String displayQuery = sourceResult.getSuggestionDisplayQuery();
295        if (DBG) {
296            Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1()
297                    + ",displayQuery="+ displayQuery);
298        }
299        if (TextUtils.isEmpty(displayQuery)) {
300            restoreUserQuery();
301        } else {
302            setQuery(displayQuery, false);
303        }
304    }
305
306    protected void onSourceSelected() {
307        if (DBG) Log.d(TAG, "No suggestion selected");
308        restoreUserQuery();
309    }
310
311    /**
312     * Hides the input method.
313     */
314    protected void hideInputMethod() {
315        InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
316        if (imm != null) {
317            imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0);
318        }
319    }
320
321    /**
322     * Hides the input method when the suggestions get focus.
323     */
324    private class SuggestListFocusListener implements OnFocusChangeListener {
325        public void onFocusChange(View v, boolean focused) {
326            if (v == mQueryTextView) {
327                if (!focused) {
328                    hideInputMethod();
329                } else {
330                    // TODO: clear list selection?
331                }
332            }
333        }
334    }
335
336    private void startSearchProgress() {
337        // TODO: Cache animation between calls?
338        mSearchGoButton.setImageResource(R.drawable.searching);
339        Animatable animation = (Animatable) mSearchGoButton.getDrawable();
340        animation.start();
341    }
342
343    private void stopSearchProgress() {
344        Drawable animation = mSearchGoButton.getDrawable();
345        if (animation instanceof Animatable) {
346            // TODO: Is this needed, or is it done automatically when the
347            // animation is removed?
348            ((Animatable) animation).stop();
349        }
350        mSearchGoButton.setImageResource(R.drawable.ic_btn_search);
351    }
352
353    private void scrollPastTabs() {
354        // TODO: Right after starting, the scroll view hasn't been measured,
355        // so it doesn't know whether its contents are tall enough to scroll.
356        int yOffset = mSuggestionsView.getTabHeight();
357        mSuggestionsScrollView.scrollTo(0, yOffset);
358        if (DBG) {
359            Log.d(TAG, "After scrollTo(0," + yOffset + "), scrollY="
360                    + mSuggestionsScrollView.getScrollY());
361        }
362    }
363
364    private void updateSuggestions(String query) {
365        LatencyTracker latency = new LatencyTracker(TAG);
366        Suggestions suggestions = getSuggestionsProvider().getSuggestions(query);
367        latency.addEvent("getSuggestions_done");
368        if (!suggestions.isDone()) {
369            suggestions.registerDataSetObserver(new ProgressUpdater(suggestions));
370            startSearchProgress();
371        } else {
372            stopSearchProgress();
373        }
374        mSuggestionsAdapter.setSuggestions(suggestions);
375        scrollPastTabs();
376        latency.addEvent("shortcuts_shown");
377        long userVisibleLatency = latency.getUserVisibleLatency();
378        if (DBG) {
379            Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms.");
380        }
381    }
382
383    /**
384     * Filters the suggestions list when the search text changes.
385     */
386    private class SearchTextWatcher implements TextWatcher {
387        public void afterTextChanged(Editable s) {
388            if (mUpdateSuggestions) {
389                String query = s == null ? "" : s.toString();
390                mUserQuery = query;
391                updateSuggestions(query);
392            }
393        }
394
395        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
396        }
397
398        public void onTextChanged(CharSequence s, int start, int before, int count) {
399        }
400    }
401
402    /**
403     * Handles non-text keys in the query text view.
404     */
405    private class QueryTextViewKeyListener implements View.OnKeyListener {
406        public boolean onKey(View view, int keyCode, KeyEvent event) {
407            // Handle IME search action key
408            if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
409                onSearchClicked();
410            }
411            return false;
412        }
413    }
414
415    private class InputMethodCloser implements SuggestionsView.InteractionListener {
416        public void onInteraction() {
417            hideInputMethod();
418        }
419    }
420
421    private class ClickHandler implements SuggestionClickListener {
422       public void onIconClicked(SuggestionPosition suggestion, Rect rect) {
423           SearchActivity.this.onIconClicked(suggestion, rect);
424       }
425
426       public void onItemClicked(SuggestionPosition suggestion) {
427           SearchActivity.this.onSuggestionClicked(suggestion);
428       }
429    }
430
431    /**
432     * Listens for clicks on the search button.
433     */
434    private class SearchGoButtonClickListener implements View.OnClickListener {
435        public void onClick(View view) {
436            onSearchClicked();
437        }
438    }
439
440    /**
441     * Listens for clicks on the voice search button.
442     */
443    private class VoiceSearchButtonClickListener implements View.OnClickListener {
444        public void onClick(View view) {
445            onVoiceSearchClicked();
446        }
447    }
448
449    /**
450     * Updates the progress bar when the suggestions adapter changes its progress.
451     */
452    private class ProgressUpdater extends DataSetObserver {
453        private final Suggestions mSuggestions;
454
455        public ProgressUpdater(Suggestions suggestions) {
456            mSuggestions = suggestions;
457        }
458
459        @Override
460        public void onChanged() {
461            if (mSuggestions.isDone()) {
462                stopSearchProgress();
463            }
464        }
465    }
466}
467