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