1/*
2 * Copyright (C) 2010 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.ui;
18
19import com.android.quicksearchbox.Corpora;
20import com.android.quicksearchbox.Corpus;
21import com.android.quicksearchbox.CorpusResult;
22import com.android.quicksearchbox.Logger;
23import com.android.quicksearchbox.Promoter;
24import com.android.quicksearchbox.QsbApplication;
25import com.android.quicksearchbox.R;
26import com.android.quicksearchbox.SearchActivity;
27import com.android.quicksearchbox.SuggestionCursor;
28import com.android.quicksearchbox.Suggestions;
29import com.android.quicksearchbox.VoiceSearch;
30
31import android.content.Context;
32import android.database.DataSetObserver;
33import android.graphics.drawable.Drawable;
34import android.text.Editable;
35import android.text.TextUtils;
36import android.text.TextWatcher;
37import android.util.AttributeSet;
38import android.util.Log;
39import android.view.KeyEvent;
40import android.view.View;
41import android.view.inputmethod.CompletionInfo;
42import android.view.inputmethod.InputMethodManager;
43import android.widget.AbsListView;
44import android.widget.ImageButton;
45import android.widget.ListAdapter;
46import android.widget.RelativeLayout;
47import android.widget.TextView;
48import android.widget.TextView.OnEditorActionListener;
49
50import java.util.ArrayList;
51import java.util.Arrays;
52
53public abstract class SearchActivityView extends RelativeLayout {
54    protected static final boolean DBG = false;
55    protected static final String TAG = "QSB.SearchActivityView";
56
57    // The string used for privateImeOptions to identify to the IME that it should not show
58    // a microphone button since one already exists in the search dialog.
59    // TODO: This should move to android-common or something.
60    private static final String IME_OPTION_NO_MICROPHONE = "nm";
61
62    private Corpus mCorpus;
63
64    protected QueryTextView mQueryTextView;
65    // True if the query was empty on the previous call to updateQuery()
66    protected boolean mQueryWasEmpty = true;
67    protected Drawable mQueryTextEmptyBg;
68    protected Drawable mQueryTextNotEmptyBg;
69
70    protected SuggestionsListView<ListAdapter> mSuggestionsView;
71    protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter;
72
73    protected ImageButton mSearchCloseButton;
74    protected ImageButton mSearchGoButton;
75    protected ImageButton mVoiceSearchButton;
76
77    protected ButtonsKeyListener mButtonsKeyListener;
78
79    private boolean mUpdateSuggestions;
80
81    private QueryListener mQueryListener;
82    private SearchClickListener mSearchClickListener;
83    protected View.OnClickListener mExitClickListener;
84
85    public SearchActivityView(Context context) {
86        super(context);
87    }
88
89    public SearchActivityView(Context context, AttributeSet attrs) {
90        super(context, attrs);
91    }
92
93    public SearchActivityView(Context context, AttributeSet attrs, int defStyle) {
94        super(context, attrs, defStyle);
95    }
96
97    @Override
98    protected void onFinishInflate() {
99        mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text);
100
101        mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions);
102        mSuggestionsView.setOnScrollListener(new InputMethodCloser());
103        mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener());
104        mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener());
105
106        mSuggestionsAdapter = createSuggestionsAdapter();
107        // TODO: why do we need focus listeners both on the SuggestionsView and the individual
108        // suggestions?
109        mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener());
110
111        mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn);
112        mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn);
113        mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn);
114        mVoiceSearchButton.setImageDrawable(getVoiceSearchIcon());
115
116        mQueryTextView.addTextChangedListener(new SearchTextWatcher());
117        mQueryTextView.setOnEditorActionListener(new QueryTextEditorActionListener());
118        mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener());
119        mQueryTextEmptyBg = mQueryTextView.getBackground();
120
121        mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener());
122
123        mButtonsKeyListener = new ButtonsKeyListener();
124        mSearchGoButton.setOnKeyListener(mButtonsKeyListener);
125        mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener);
126        if (mSearchCloseButton != null) {
127            mSearchCloseButton.setOnKeyListener(mButtonsKeyListener);
128            mSearchCloseButton.setOnClickListener(new CloseClickListener());
129        }
130
131        mUpdateSuggestions = true;
132    }
133
134    public abstract void onResume();
135
136    public abstract void onStop();
137
138    public void onPause() {
139        // Override if necessary
140    }
141
142    public void start() {
143        mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver());
144        mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter);
145    }
146
147    public void destroy() {
148        mSuggestionsView.setSuggestionsAdapter(null);  // closes mSuggestionsAdapter
149    }
150
151    // TODO: Get rid of this. To make it more easily testable,
152    // the SearchActivityView should not depend on QsbApplication.
153    protected QsbApplication getQsbApplication() {
154        return QsbApplication.get(getContext());
155    }
156
157    protected Drawable getVoiceSearchIcon() {
158        return getResources().getDrawable(R.drawable.ic_btn_speak_now);
159    }
160
161    protected VoiceSearch getVoiceSearch() {
162        return getQsbApplication().getVoiceSearch();
163    }
164
165    protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() {
166        return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter(
167                getQsbApplication().getSuggestionViewFactory()));
168    }
169
170    protected Corpora getCorpora() {
171        return getQsbApplication().getCorpora();
172    }
173
174    public Corpus getCorpus() {
175        return mCorpus;
176    }
177
178    protected abstract Promoter createSuggestionsPromoter();
179
180    protected Corpus getCorpus(String sourceName) {
181        if (sourceName == null) return null;
182        Corpus corpus = getCorpora().getCorpus(sourceName);
183        if (corpus == null) {
184            Log.w(TAG, "Unknown corpus " + sourceName);
185            return null;
186        }
187        return corpus;
188    }
189
190    public void onCorpusSelected(String corpusName) {
191        setCorpus(corpusName);
192        focusQueryTextView();
193        showInputMethodForQuery();
194    }
195
196    public void setCorpus(String corpusName) {
197        if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")");
198        Corpus corpus = getCorpus(corpusName);
199        setCorpus(corpus);
200        updateUi();
201    }
202
203    protected void setCorpus(Corpus corpus) {
204        mCorpus = corpus;
205        mSuggestionsAdapter.setPromoter(createSuggestionsPromoter());
206        Suggestions suggestions = getSuggestions();
207        if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) {
208            getActivity().updateSuggestions();
209        }
210    }
211
212    public String getCorpusName() {
213        Corpus corpus = getCorpus();
214        return corpus == null ? null : corpus.getName();
215    }
216
217    public abstract Corpus getSearchCorpus();
218
219    public Corpus getWebCorpus() {
220        Corpus webCorpus = getCorpora().getWebCorpus();
221        if (webCorpus == null) {
222            Log.e(TAG, "No web corpus");
223        }
224        return webCorpus;
225    }
226
227    public void setMaxPromotedSuggestions(int maxPromoted) {
228        mSuggestionsView.setLimitSuggestionsToViewHeight(false);
229        mSuggestionsAdapter.setMaxPromoted(maxPromoted);
230    }
231
232    public void limitSuggestionsToViewHeight() {
233        mSuggestionsView.setLimitSuggestionsToViewHeight(true);
234    }
235
236    public void setMaxPromotedResults(int maxPromoted) {
237    }
238
239    public void limitResultsToViewHeight() {
240    }
241
242    public void setQueryListener(QueryListener listener) {
243        mQueryListener = listener;
244    }
245
246    public void setSearchClickListener(SearchClickListener listener) {
247        mSearchClickListener = listener;
248    }
249
250    public abstract void showCorpusSelectionDialog();
251
252    public void setVoiceSearchButtonClickListener(View.OnClickListener listener) {
253        if (mVoiceSearchButton != null) {
254            mVoiceSearchButton.setOnClickListener(listener);
255        }
256    }
257
258    public void setSuggestionClickListener(final SuggestionClickListener listener) {
259        mSuggestionsAdapter.setSuggestionClickListener(listener);
260        mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() {
261            @Override
262            public void onCommitCompletion(int position) {
263                mSuggestionsAdapter.onSuggestionClicked(position);
264            }
265        });
266    }
267
268    public void setExitClickListener(final View.OnClickListener listener) {
269        mExitClickListener = listener;
270    }
271
272    public Suggestions getSuggestions() {
273        return mSuggestionsAdapter.getSuggestions();
274    }
275
276    public SuggestionCursor getCurrentPromotedSuggestions() {
277        return mSuggestionsAdapter.getCurrentPromotedSuggestions();
278    }
279
280    public void setSuggestions(Suggestions suggestions) {
281        suggestions.acquire();
282        mSuggestionsAdapter.setSuggestions(suggestions);
283    }
284
285    public void clearSuggestions() {
286        mSuggestionsAdapter.setSuggestions(null);
287    }
288
289    public String getQuery() {
290        CharSequence q = mQueryTextView.getText();
291        return q == null ? "" : q.toString();
292    }
293
294    public boolean isQueryEmpty() {
295        return TextUtils.isEmpty(getQuery());
296    }
297
298    /**
299     * Sets the text in the query box. Does not update the suggestions.
300     */
301    public void setQuery(String query, boolean selectAll) {
302        mUpdateSuggestions = false;
303        mQueryTextView.setText(query);
304        mQueryTextView.setTextSelection(selectAll);
305        mUpdateSuggestions = true;
306    }
307
308    protected SearchActivity getActivity() {
309        Context context = getContext();
310        if (context instanceof SearchActivity) {
311            return (SearchActivity) context;
312        } else {
313            return null;
314        }
315    }
316
317    public void hideSuggestions() {
318        mSuggestionsView.setVisibility(GONE);
319    }
320
321    public void showSuggestions() {
322        mSuggestionsView.setVisibility(VISIBLE);
323    }
324
325    public void focusQueryTextView() {
326        mQueryTextView.requestFocus();
327    }
328
329    protected void updateUi() {
330        updateUi(isQueryEmpty());
331    }
332
333    protected void updateUi(boolean queryEmpty) {
334        updateQueryTextView(queryEmpty);
335        updateSearchGoButton(queryEmpty);
336        updateVoiceSearchButton(queryEmpty);
337    }
338
339    protected void updateQueryTextView(boolean queryEmpty) {
340        if (queryEmpty) {
341            if (isSearchCorpusWeb()) {
342                mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg);
343                mQueryTextView.setHint(null);
344            } else {
345                if (mQueryTextNotEmptyBg == null) {
346                    mQueryTextNotEmptyBg =
347                            getResources().getDrawable(R.drawable.textfield_search_empty);
348                }
349                mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg);
350                Corpus corpus = getCorpus();
351                mQueryTextView.setHint(corpus == null ? "" : corpus.getHint());
352            }
353        } else {
354            mQueryTextView.setBackgroundResource(R.drawable.textfield_search);
355        }
356    }
357
358    private void updateSearchGoButton(boolean queryEmpty) {
359        if (queryEmpty) {
360            mSearchGoButton.setVisibility(View.GONE);
361        } else {
362            mSearchGoButton.setVisibility(View.VISIBLE);
363        }
364    }
365
366    protected void updateVoiceSearchButton(boolean queryEmpty) {
367        if (shouldShowVoiceSearch(queryEmpty)
368                && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) {
369            mVoiceSearchButton.setVisibility(View.VISIBLE);
370            mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE);
371        } else {
372            mVoiceSearchButton.setVisibility(View.GONE);
373            mQueryTextView.setPrivateImeOptions(null);
374        }
375    }
376
377    protected boolean shouldShowVoiceSearch(boolean queryEmpty) {
378        return queryEmpty;
379    }
380
381    /**
382     * Hides the input method.
383     */
384    protected void hideInputMethod() {
385        InputMethodManager imm = (InputMethodManager)
386                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
387        if (imm != null) {
388            imm.hideSoftInputFromWindow(getWindowToken(), 0);
389        }
390    }
391
392    public abstract void considerHidingInputMethod();
393
394    public void showInputMethodForQuery() {
395        mQueryTextView.showInputMethod();
396    }
397
398    /**
399     * Dismiss the activity if BACK is pressed when the search box is empty.
400     */
401    @Override
402    public boolean dispatchKeyEventPreIme(KeyEvent event) {
403        SearchActivity activity = getActivity();
404        if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK
405                && isQueryEmpty()) {
406            KeyEvent.DispatcherState state = getKeyDispatcherState();
407            if (state != null) {
408                if (event.getAction() == KeyEvent.ACTION_DOWN
409                        && event.getRepeatCount() == 0) {
410                    state.startTracking(event, this);
411                    return true;
412                } else if (event.getAction() == KeyEvent.ACTION_UP
413                        && !event.isCanceled() && state.isTracking(event)) {
414                    hideInputMethod();
415                    activity.onBackPressed();
416                    return true;
417                }
418            }
419        }
420        return super.dispatchKeyEventPreIme(event);
421    }
422
423    /**
424     * If the input method is in fullscreen mode, and the selector corpus
425     * is All or Web, use the web search suggestions as completions.
426     */
427    protected void updateInputMethodSuggestions() {
428        InputMethodManager imm = (InputMethodManager)
429                getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
430        if (imm == null || !imm.isFullscreenMode()) return;
431        Suggestions suggestions = mSuggestionsAdapter.getSuggestions();
432        if (suggestions == null) return;
433        CompletionInfo[] completions = webSuggestionsToCompletions(suggestions);
434        if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")");
435        imm.displayCompletions(mQueryTextView, completions);
436    }
437
438    private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) {
439        // TODO: This should also include include web search shortcuts
440        CorpusResult cursor = suggestions.getWebResult();
441        if (cursor == null) return null;
442        int count = cursor.getCount();
443        ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count);
444        boolean usingWebCorpus = isSearchCorpusWeb();
445        for (int i = 0; i < count; i++) {
446            cursor.moveTo(i);
447            if (!usingWebCorpus || cursor.isWebSearchSuggestion()) {
448                String text1 = cursor.getSuggestionText1();
449                completions.add(new CompletionInfo(i, i, text1));
450            }
451        }
452        return completions.toArray(new CompletionInfo[completions.size()]);
453    }
454
455    protected void onSuggestionsChanged() {
456        updateInputMethodSuggestions();
457    }
458
459    /**
460     * Checks if the corpus used for typed searches is the web corpus.
461     */
462    protected boolean isSearchCorpusWeb() {
463        Corpus corpus = getSearchCorpus();
464        return corpus != null && corpus.isWebCorpus();
465    }
466
467    protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter,
468            long suggestionId, int keyCode, KeyEvent event) {
469        // Treat enter or search as a click
470        if (       keyCode == KeyEvent.KEYCODE_ENTER
471                || keyCode == KeyEvent.KEYCODE_SEARCH
472                || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
473            if (adapter != null) {
474                adapter.onSuggestionClicked(suggestionId);
475                return true;
476            } else {
477                return false;
478            }
479        }
480
481        return false;
482    }
483
484    protected boolean onSearchClicked(int method) {
485        if (mSearchClickListener != null) {
486            return mSearchClickListener.onSearchClicked(method);
487        }
488        return false;
489    }
490
491    /**
492     * Filters the suggestions list when the search text changes.
493     */
494    private class SearchTextWatcher implements TextWatcher {
495        public void afterTextChanged(Editable s) {
496            boolean empty = s.length() == 0;
497            if (empty != mQueryWasEmpty) {
498                mQueryWasEmpty = empty;
499                updateUi(empty);
500            }
501            if (mUpdateSuggestions) {
502                if (mQueryListener != null) {
503                    mQueryListener.onQueryChanged();
504                }
505            }
506        }
507
508        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
509        }
510
511        public void onTextChanged(CharSequence s, int start, int before, int count) {
512        }
513    }
514
515    /**
516     * Handles key events on the suggestions list view.
517     */
518    protected class SuggestionsViewKeyListener implements View.OnKeyListener {
519        public boolean onKey(View v, int keyCode, KeyEvent event) {
520            if (event.getAction() == KeyEvent.ACTION_DOWN
521                    && v instanceof SuggestionsListView<?>) {
522                SuggestionsListView<?> listView = (SuggestionsListView<?>) v;
523                if (onSuggestionKeyDown(listView.getSuggestionsAdapter(),
524                        listView.getSelectedItemId(), keyCode, event)) {
525                    return true;
526                }
527            }
528            return forwardKeyToQueryTextView(keyCode, event);
529        }
530    }
531
532    private class InputMethodCloser implements SuggestionsView.OnScrollListener {
533
534        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
535                int totalItemCount) {
536        }
537
538        public void onScrollStateChanged(AbsListView view, int scrollState) {
539            considerHidingInputMethod();
540        }
541    }
542
543    /**
544     * Listens for clicks on the source selector.
545     */
546    private class SearchGoButtonClickListener implements View.OnClickListener {
547        public void onClick(View view) {
548            onSearchClicked(Logger.SEARCH_METHOD_BUTTON);
549        }
550    }
551
552    /**
553     * This class handles enter key presses in the query text view.
554     */
555    private class QueryTextEditorActionListener implements OnEditorActionListener {
556        public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
557            boolean consumed = false;
558            if (event != null) {
559                if (event.getAction() == KeyEvent.ACTION_UP) {
560                    consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD);
561                } else if (event.getAction() == KeyEvent.ACTION_DOWN) {
562                    // we have to consume the down event so that we receive the up event too
563                    consumed = true;
564                }
565            }
566            if (DBG) Log.d(TAG, "onEditorAction consumed=" + consumed);
567            return consumed;
568        }
569    }
570
571    /**
572     * Handles key events on the search and voice search buttons,
573     * by refocusing to EditText.
574     */
575    private class ButtonsKeyListener implements View.OnKeyListener {
576        public boolean onKey(View v, int keyCode, KeyEvent event) {
577            return forwardKeyToQueryTextView(keyCode, event);
578        }
579    }
580
581    private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) {
582        if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) {
583            if (DBG) Log.d(TAG, "Forwarding key to query box: " + event);
584            if (mQueryTextView.requestFocus()) {
585                return mQueryTextView.dispatchKeyEvent(event);
586            }
587        }
588        return false;
589    }
590
591    private boolean shouldForwardToQueryTextView(int keyCode) {
592        switch (keyCode) {
593            case KeyEvent.KEYCODE_DPAD_UP:
594            case KeyEvent.KEYCODE_DPAD_DOWN:
595            case KeyEvent.KEYCODE_DPAD_LEFT:
596            case KeyEvent.KEYCODE_DPAD_RIGHT:
597            case KeyEvent.KEYCODE_DPAD_CENTER:
598            case KeyEvent.KEYCODE_ENTER:
599            case KeyEvent.KEYCODE_SEARCH:
600                return false;
601            default:
602                return true;
603        }
604    }
605
606    /**
607     * Hides the input method when the suggestions get focus.
608     */
609    private class SuggestListFocusListener implements OnFocusChangeListener {
610        public void onFocusChange(View v, boolean focused) {
611            if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused);
612            if (focused) {
613                considerHidingInputMethod();
614            }
615        }
616    }
617
618    private class QueryTextViewFocusListener implements OnFocusChangeListener {
619        public void onFocusChange(View v, boolean focused) {
620            if (DBG) Log.d(TAG, "Query focus change, now: " + focused);
621            if (focused) {
622                // The query box got focus, show the input method
623                showInputMethodForQuery();
624            }
625        }
626    }
627
628    protected class SuggestionsObserver extends DataSetObserver {
629        @Override
630        public void onChanged() {
631            onSuggestionsChanged();
632        }
633    }
634
635    public interface QueryListener {
636        void onQueryChanged();
637    }
638
639    public interface SearchClickListener {
640        boolean onSearchClicked(int method);
641    }
642
643    private class CloseClickListener implements OnClickListener {
644        public void onClick(View v) {
645            if (!isQueryEmpty()) {
646                mQueryTextView.setText("");
647            } else {
648                mExitClickListener.onClick(v);
649            }
650        }
651    }
652}
653