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.browser;
18
19import android.app.SearchManager;
20import android.content.Context;
21import android.database.Cursor;
22import android.net.Uri;
23import android.os.AsyncTask;
24import android.provider.BrowserContract;
25import android.text.Html;
26import android.text.TextUtils;
27import android.view.LayoutInflater;
28import android.view.View;
29import android.view.View.OnClickListener;
30import android.view.ViewGroup;
31import android.widget.BaseAdapter;
32import android.widget.Filter;
33import android.widget.Filterable;
34import android.widget.ImageView;
35import android.widget.TextView;
36
37import com.android.browser.provider.BrowserProvider2.OmniboxSuggestions;
38import com.android.browser.search.SearchEngine;
39
40import java.util.ArrayList;
41import java.util.List;
42
43/**
44 * adapter to wrap multiple cursors for url/search completions
45 */
46public class SuggestionsAdapter extends BaseAdapter implements Filterable,
47        OnClickListener {
48
49    public static final int TYPE_BOOKMARK = 0;
50    public static final int TYPE_HISTORY = 1;
51    public static final int TYPE_SUGGEST_URL = 2;
52    public static final int TYPE_SEARCH = 3;
53    public static final int TYPE_SUGGEST = 4;
54
55    private static final String[] COMBINED_PROJECTION = {
56            OmniboxSuggestions._ID,
57            OmniboxSuggestions.TITLE,
58            OmniboxSuggestions.URL,
59            OmniboxSuggestions.IS_BOOKMARK
60            };
61
62    private static final String COMBINED_SELECTION =
63            "(url LIKE ? OR url LIKE ? OR url LIKE ? OR url LIKE ? OR title LIKE ?)";
64
65    final Context mContext;
66    final Filter mFilter;
67    SuggestionResults mMixedResults;
68    List<SuggestItem> mSuggestResults, mFilterResults;
69    List<CursorSource> mSources;
70    boolean mLandscapeMode;
71    final CompletionListener mListener;
72    final int mLinesPortrait;
73    final int mLinesLandscape;
74    final Object mResultsLock = new Object();
75    boolean mIncognitoMode;
76    BrowserSettings mSettings;
77
78    interface CompletionListener {
79
80        public void onSearch(String txt);
81
82        public void onSelect(String txt, int type, String extraData);
83
84    }
85
86    public SuggestionsAdapter(Context ctx, CompletionListener listener) {
87        mContext = ctx;
88        mSettings = BrowserSettings.getInstance();
89        mListener = listener;
90        mLinesPortrait = mContext.getResources().
91                getInteger(R.integer.max_suggest_lines_portrait);
92        mLinesLandscape = mContext.getResources().
93                getInteger(R.integer.max_suggest_lines_landscape);
94
95        mFilter = new SuggestFilter();
96        addSource(new CombinedCursor());
97    }
98
99    public void setLandscapeMode(boolean mode) {
100        mLandscapeMode = mode;
101        notifyDataSetChanged();
102    }
103
104    public void addSource(CursorSource c) {
105        if (mSources == null) {
106            mSources = new ArrayList<CursorSource>(5);
107        }
108        mSources.add(c);
109    }
110
111    @Override
112    public void onClick(View v) {
113        SuggestItem item = (SuggestItem) ((View) v.getParent()).getTag();
114
115        if (R.id.icon2 == v.getId()) {
116            // replace input field text with suggestion text
117            mListener.onSearch(getSuggestionUrl(item));
118        } else {
119            mListener.onSelect(getSuggestionUrl(item), item.type, item.extra);
120        }
121    }
122
123    @Override
124    public Filter getFilter() {
125        return mFilter;
126    }
127
128    @Override
129    public int getCount() {
130        return (mMixedResults == null) ? 0 : mMixedResults.getLineCount();
131    }
132
133    @Override
134    public SuggestItem getItem(int position) {
135        if (mMixedResults == null) {
136            return null;
137        }
138        return mMixedResults.items.get(position);
139    }
140
141    @Override
142    public long getItemId(int position) {
143        return position;
144    }
145
146    @Override
147    public View getView(int position, View convertView, ViewGroup parent) {
148        final LayoutInflater inflater = LayoutInflater.from(mContext);
149        View view = convertView;
150        if (view == null) {
151            view = inflater.inflate(R.layout.suggestion_item, parent, false);
152        }
153        bindView(view, getItem(position));
154        return view;
155    }
156
157    private void bindView(View view, SuggestItem item) {
158        // store item for click handling
159        view.setTag(item);
160        TextView tv1 = (TextView) view.findViewById(android.R.id.text1);
161        TextView tv2 = (TextView) view.findViewById(android.R.id.text2);
162        ImageView ic1 = (ImageView) view.findViewById(R.id.icon1);
163        View ic2 = view.findViewById(R.id.icon2);
164        View div = view.findViewById(R.id.divider);
165        tv1.setText(Html.fromHtml(item.title));
166        if (TextUtils.isEmpty(item.url)) {
167            tv2.setVisibility(View.GONE);
168            tv1.setMaxLines(2);
169        } else {
170            tv2.setVisibility(View.VISIBLE);
171            tv2.setText(item.url);
172            tv1.setMaxLines(1);
173        }
174        int id = -1;
175        switch (item.type) {
176            case TYPE_SUGGEST:
177            case TYPE_SEARCH:
178                id = R.drawable.ic_search_category_suggest;
179                break;
180            case TYPE_BOOKMARK:
181                id = R.drawable.ic_search_category_bookmark;
182                break;
183            case TYPE_HISTORY:
184                id = R.drawable.ic_search_category_history;
185                break;
186            case TYPE_SUGGEST_URL:
187                id = R.drawable.ic_search_category_browser;
188                break;
189            default:
190                id = -1;
191        }
192        if (id != -1) {
193            ic1.setImageDrawable(mContext.getResources().getDrawable(id));
194        }
195        ic2.setVisibility(((TYPE_SUGGEST == item.type)
196                || (TYPE_SEARCH == item.type))
197                ? View.VISIBLE : View.GONE);
198        div.setVisibility(ic2.getVisibility());
199        ic2.setOnClickListener(this);
200        view.findViewById(R.id.suggestion).setOnClickListener(this);
201    }
202
203    class SlowFilterTask extends AsyncTask<CharSequence, Void, List<SuggestItem>> {
204
205        @Override
206        protected List<SuggestItem> doInBackground(CharSequence... params) {
207            SuggestCursor cursor = new SuggestCursor();
208            cursor.runQuery(params[0]);
209            List<SuggestItem> results = new ArrayList<SuggestItem>();
210            int count = cursor.getCount();
211            for (int i = 0; i < count; i++) {
212                results.add(cursor.getItem());
213                cursor.moveToNext();
214            }
215            cursor.close();
216            return results;
217        }
218
219        @Override
220        protected void onPostExecute(List<SuggestItem> items) {
221            mSuggestResults = items;
222            mMixedResults = buildSuggestionResults();
223            notifyDataSetChanged();
224        }
225    }
226
227    SuggestionResults buildSuggestionResults() {
228        SuggestionResults mixed = new SuggestionResults();
229        List<SuggestItem> filter, suggest;
230        synchronized (mResultsLock) {
231            filter = mFilterResults;
232            suggest = mSuggestResults;
233        }
234        if (filter != null) {
235            for (SuggestItem item : filter) {
236                mixed.addResult(item);
237            }
238        }
239        if (suggest != null) {
240            for (SuggestItem item : suggest) {
241                mixed.addResult(item);
242            }
243        }
244        return mixed;
245    }
246
247    class SuggestFilter extends Filter {
248
249        @Override
250        public CharSequence convertResultToString(Object item) {
251            if (item == null) {
252                return "";
253            }
254            SuggestItem sitem = (SuggestItem) item;
255            if (sitem.title != null) {
256                return sitem.title;
257            } else {
258                return sitem.url;
259            }
260        }
261
262        void startSuggestionsAsync(final CharSequence constraint) {
263            if (!mIncognitoMode) {
264                new SlowFilterTask().execute(constraint);
265            }
266        }
267
268        private boolean shouldProcessEmptyQuery() {
269            final SearchEngine searchEngine = mSettings.getSearchEngine();
270            return searchEngine.wantsEmptyQuery();
271        }
272
273        @Override
274        protected FilterResults performFiltering(CharSequence constraint) {
275            FilterResults res = new FilterResults();
276            if (TextUtils.isEmpty(constraint) && !shouldProcessEmptyQuery()) {
277                res.count = 0;
278                res.values = null;
279                return res;
280            }
281            startSuggestionsAsync(constraint);
282            List<SuggestItem> filterResults = new ArrayList<SuggestItem>();
283            if (constraint != null) {
284                for (CursorSource sc : mSources) {
285                    sc.runQuery(constraint);
286                }
287                mixResults(filterResults);
288            }
289            synchronized (mResultsLock) {
290                mFilterResults = filterResults;
291            }
292            SuggestionResults mixed = buildSuggestionResults();
293            res.count = mixed.getLineCount();
294            res.values = mixed;
295            return res;
296        }
297
298        void mixResults(List<SuggestItem> results) {
299            int maxLines = getMaxLines();
300            for (int i = 0; i < mSources.size(); i++) {
301                CursorSource s = mSources.get(i);
302                int n = Math.min(s.getCount(), maxLines);
303                maxLines -= n;
304                boolean more = false;
305                for (int j = 0; j < n; j++) {
306                    results.add(s.getItem());
307                    more = s.moveToNext();
308                }
309            }
310        }
311
312        @Override
313        protected void publishResults(CharSequence constraint, FilterResults fresults) {
314            if (fresults.values instanceof SuggestionResults) {
315                mMixedResults = (SuggestionResults) fresults.values;
316                notifyDataSetChanged();
317            }
318        }
319    }
320
321    private int getMaxLines() {
322        int maxLines = mLandscapeMode ? mLinesLandscape : mLinesPortrait;
323        maxLines = (int) Math.ceil(maxLines / 2.0);
324        return maxLines;
325    }
326
327    /**
328     * sorted list of results of a suggestion query
329     *
330     */
331    class SuggestionResults {
332
333        ArrayList<SuggestItem> items;
334        // count per type
335        int[] counts;
336
337        SuggestionResults() {
338            items = new ArrayList<SuggestItem>(24);
339            // n of types:
340            counts = new int[5];
341        }
342
343        int getTypeCount(int type) {
344            return counts[type];
345        }
346
347        void addResult(SuggestItem item) {
348            int ix = 0;
349            while ((ix < items.size()) && (item.type >= items.get(ix).type))
350                ix++;
351            items.add(ix, item);
352            counts[item.type]++;
353        }
354
355        int getLineCount() {
356            return Math.min((mLandscapeMode ? mLinesLandscape : mLinesPortrait), items.size());
357        }
358
359        @Override
360        public String toString() {
361            if (items == null) return null;
362            if (items.size() == 0) return "[]";
363            StringBuilder sb = new StringBuilder();
364            for (int i = 0; i < items.size(); i++) {
365                SuggestItem item = items.get(i);
366                sb.append(item.type + ": " + item.title);
367                if (i < items.size() - 1) {
368                    sb.append(", ");
369                }
370            }
371            return sb.toString();
372        }
373    }
374
375    /**
376     * data object to hold suggestion values
377     */
378    public class SuggestItem {
379        public String title;
380        public String url;
381        public int type;
382        public String extra;
383
384        public SuggestItem(String text, String u, int t) {
385            title = text;
386            url = u;
387            type = t;
388        }
389
390    }
391
392    abstract class CursorSource {
393
394        Cursor mCursor;
395
396        boolean moveToNext() {
397            return mCursor.moveToNext();
398        }
399
400        public abstract void runQuery(CharSequence constraint);
401
402        public abstract SuggestItem getItem();
403
404        public int getCount() {
405            return (mCursor != null) ? mCursor.getCount() : 0;
406        }
407
408        public void close() {
409            if (mCursor != null) {
410                mCursor.close();
411            }
412        }
413    }
414
415    /**
416     * combined bookmark & history source
417     */
418    class CombinedCursor extends CursorSource {
419
420        @Override
421        public SuggestItem getItem() {
422            if ((mCursor != null) && (!mCursor.isAfterLast())) {
423                String title = mCursor.getString(1);
424                String url = mCursor.getString(2);
425                boolean isBookmark = (mCursor.getInt(3) == 1);
426                return new SuggestItem(getTitle(title, url), getUrl(title, url),
427                        isBookmark ? TYPE_BOOKMARK : TYPE_HISTORY);
428            }
429            return null;
430        }
431
432        @Override
433        public void runQuery(CharSequence constraint) {
434            // constraint != null
435            if (mCursor != null) {
436                mCursor.close();
437            }
438            String like = constraint + "%";
439            String[] args = null;
440            String selection = null;
441            if (like.startsWith("http") || like.startsWith("file")) {
442                args = new String[1];
443                args[0] = like;
444                selection = "url LIKE ?";
445            } else {
446                args = new String[5];
447                args[0] = "http://" + like;
448                args[1] = "http://www." + like;
449                args[2] = "https://" + like;
450                args[3] = "https://www." + like;
451                // To match against titles.
452                args[4] = like;
453                selection = COMBINED_SELECTION;
454            }
455            Uri.Builder ub = OmniboxSuggestions.CONTENT_URI.buildUpon();
456            ub.appendQueryParameter(BrowserContract.PARAM_LIMIT,
457                    Integer.toString(Math.max(mLinesLandscape, mLinesPortrait)));
458            mCursor =
459                    mContext.getContentResolver().query(ub.build(), COMBINED_PROJECTION,
460                            selection, (constraint != null) ? args : null, null);
461            if (mCursor != null) {
462                mCursor.moveToFirst();
463            }
464        }
465
466        /**
467         * Provides the title (text line 1) for a browser suggestion, which should be the
468         * webpage title. If the webpage title is empty, returns the stripped url instead.
469         *
470         * @return the title string to use
471         */
472        private String getTitle(String title, String url) {
473            if (TextUtils.isEmpty(title) || TextUtils.getTrimmedLength(title) == 0) {
474                title = UrlUtils.stripUrl(url);
475            }
476            return title;
477        }
478
479        /**
480         * Provides the subtitle (text line 2) for a browser suggestion, which should be the
481         * webpage url. If the webpage title is empty, then the url should go in the title
482         * instead, and the subtitle should be empty, so this would return null.
483         *
484         * @return the subtitle string to use, or null if none
485         */
486        private String getUrl(String title, String url) {
487            if (TextUtils.isEmpty(title)
488                    || TextUtils.getTrimmedLength(title) == 0
489                    || title.equals(url)) {
490                return null;
491            } else {
492                return UrlUtils.stripUrl(url);
493            }
494        }
495    }
496
497    class SuggestCursor extends CursorSource {
498
499        @Override
500        public SuggestItem getItem() {
501            if (mCursor != null) {
502                String title = mCursor.getString(
503                        mCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1));
504                String text2 = mCursor.getString(
505                        mCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_2));
506                String url = mCursor.getString(
507                        mCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_2_URL));
508                String uri = mCursor.getString(
509                        mCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_DATA));
510                int type = (TextUtils.isEmpty(url)) ? TYPE_SUGGEST : TYPE_SUGGEST_URL;
511                SuggestItem item = new SuggestItem(title, url, type);
512                item.extra = mCursor.getString(
513                        mCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA));
514                return item;
515            }
516            return null;
517        }
518
519        @Override
520        public void runQuery(CharSequence constraint) {
521            if (mCursor != null) {
522                mCursor.close();
523            }
524            SearchEngine searchEngine = mSettings.getSearchEngine();
525            if (!TextUtils.isEmpty(constraint)) {
526                if (searchEngine != null && searchEngine.supportsSuggestions()) {
527                    mCursor = searchEngine.getSuggestions(mContext, constraint.toString());
528                    if (mCursor != null) {
529                        mCursor.moveToFirst();
530                    }
531                }
532            } else {
533                if (searchEngine.wantsEmptyQuery()) {
534                    mCursor = searchEngine.getSuggestions(mContext, "");
535                }
536                mCursor = null;
537            }
538        }
539
540    }
541
542    public void clearCache() {
543        mFilterResults = null;
544        mSuggestResults = null;
545        notifyDataSetInvalidated();
546    }
547
548    public void setIncognitoMode(boolean incognito) {
549        mIncognitoMode = incognito;
550        clearCache();
551    }
552
553    static String getSuggestionTitle(SuggestItem item) {
554        // There must be a better way to strip HTML from things.
555        // This method is used in multiple places. It is also more
556        // expensive than a standard html escaper.
557        return (item.title != null) ? Html.fromHtml(item.title).toString() : null;
558    }
559
560    static String getSuggestionUrl(SuggestItem item) {
561        final String title = SuggestionsAdapter.getSuggestionTitle(item);
562
563        if (TextUtils.isEmpty(item.url)) {
564            return title;
565        }
566
567        return item.url;
568    }
569}
570