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