1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- 2 * This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6 package org.mozilla.gecko.home; 7 8 import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN; 9 import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN; 10 11 import java.util.ArrayList; 12 import java.util.Collections; 13 import java.util.EnumSet; 14 import java.util.HashMap; 15 import java.util.List; 16 import java.util.Map; 17 import java.util.concurrent.Future; 18 19 import org.mozilla.gecko.GeckoProfile; 20 import org.mozilla.gecko.R; 21 import org.mozilla.gecko.Telemetry; 22 import org.mozilla.gecko.TelemetryContract; 23 import org.mozilla.gecko.db.BrowserContract.Thumbnails; 24 import org.mozilla.gecko.db.BrowserContract.TopSites; 25 import org.mozilla.gecko.db.BrowserDB; 26 import org.mozilla.gecko.gfx.BitmapUtils; 27 import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType; 28 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; 29 import org.mozilla.gecko.home.PinSiteDialog.OnSiteSelectedListener; 30 import org.mozilla.gecko.home.TopSitesGridView.OnEditPinnedSiteListener; 31 import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo; 32 import org.mozilla.gecko.icons.IconCallback; 33 import org.mozilla.gecko.icons.IconResponse; 34 import org.mozilla.gecko.icons.Icons; 35 import org.mozilla.gecko.restrictions.Restrictable; 36 import org.mozilla.gecko.restrictions.Restrictions; 37 import org.mozilla.gecko.util.StringUtils; 38 import org.mozilla.gecko.util.ThreadUtils; 39 40 import android.app.Activity; 41 import android.content.ContentResolver; 42 import android.content.Context; 43 import android.database.Cursor; 44 import android.graphics.Bitmap; 45 import android.graphics.Color; 46 import android.os.Bundle; 47 import android.os.SystemClock; 48 import android.support.v4.app.FragmentManager; 49 import android.support.v4.app.LoaderManager.LoaderCallbacks; 50 import android.support.v4.content.AsyncTaskLoader; 51 import android.support.v4.content.Loader; 52 import android.support.v4.widget.CursorAdapter; 53 import android.text.TextUtils; 54 import android.util.Log; 55 import android.view.ContextMenu; 56 import android.view.ContextMenu.ContextMenuInfo; 57 import android.view.LayoutInflater; 58 import android.view.MenuInflater; 59 import android.view.MenuItem; 60 import android.view.View; 61 import android.view.ViewGroup; 62 import android.widget.AdapterView; 63 import android.widget.ListView; 64 65 /** 66 * Fragment that displays frecency search results in a ListView. 67 */ 68 public class TopSitesPanel extends HomeFragment { 69 // Logging tag name 70 private static final String LOGTAG = "GeckoTopSitesPanel"; 71 72 // Cursor loader ID for the top sites 73 private static final int LOADER_ID_TOP_SITES = 0; 74 75 // Loader ID for thumbnails 76 private static final int LOADER_ID_THUMBNAILS = 1; 77 78 // Key for thumbnail urls 79 private static final String THUMBNAILS_URLS_KEY = "urls"; 80 81 // Adapter for the list of top sites 82 private VisitedAdapter mListAdapter; 83 84 // Adapter for the grid of top sites 85 private TopSitesGridAdapter mGridAdapter; 86 87 // List of top sites 88 private HomeListView mList; 89 90 // Grid of top sites 91 private TopSitesGridView mGrid; 92 93 // Callbacks used for the search and favicon cursor loaders 94 private CursorLoaderCallbacks mCursorLoaderCallbacks; 95 96 // Callback for thumbnail loader 97 private ThumbnailsLoaderCallbacks mThumbnailsLoaderCallbacks; 98 99 // Listener for editing pinned sites. 100 private EditPinnedSiteListener mEditPinnedSiteListener; 101 102 // Max number of entries shown in the grid from the cursor. 103 private int mMaxGridEntries; 104 105 // Time in ms until the Gecko thread is reset to normal priority. 106 private static final long PRIORITY_RESET_TIMEOUT = 10000; 107 newInstance()108 public static TopSitesPanel newInstance() { 109 return new TopSitesPanel(); 110 } 111 112 private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); 113 private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); 114 debug(final String message)115 private static void debug(final String message) { 116 if (logDebug) { 117 Log.d(LOGTAG, message); 118 } 119 } 120 trace(final String message)121 private static void trace(final String message) { 122 if (logVerbose) { 123 Log.v(LOGTAG, message); 124 } 125 } 126 127 @Override onAttach(Activity activity)128 public void onAttach(Activity activity) { 129 super.onAttach(activity); 130 131 mMaxGridEntries = activity.getResources().getInteger(R.integer.number_of_top_sites); 132 } 133 134 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)135 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 136 final View view = inflater.inflate(R.layout.home_top_sites_panel, container, false); 137 138 mList = (HomeListView) view.findViewById(R.id.list); 139 140 mGrid = new TopSitesGridView(getActivity()); 141 mList.addHeaderView(mGrid); 142 143 return view; 144 } 145 146 @Override onViewCreated(View view, Bundle savedInstanceState)147 public void onViewCreated(View view, Bundle savedInstanceState) { 148 mEditPinnedSiteListener = new EditPinnedSiteListener(); 149 150 mList.setTag(HomePager.LIST_TAG_TOP_SITES); 151 mList.setHeaderDividersEnabled(false); 152 153 mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { 154 @Override 155 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 156 final ListView list = (ListView) parent; 157 final int headerCount = list.getHeaderViewsCount(); 158 if (position < headerCount) { 159 // The click is on a header, don't do anything. 160 return; 161 } 162 163 // Absolute position for the adapter. 164 position += (mGridAdapter.getCount() - headerCount); 165 166 final Cursor c = mListAdapter.getCursor(); 167 if (c == null || !c.moveToPosition(position)) { 168 return; 169 } 170 171 final String url = c.getString(c.getColumnIndexOrThrow(TopSites.URL)); 172 173 Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "top_sites"); 174 175 // This item is a TwoLinePageRow, so we allow switch-to-tab. 176 mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); 177 } 178 }); 179 180 mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { 181 @Override 182 public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { 183 final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); 184 info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); 185 info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); 186 info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); 187 info.itemType = RemoveItemType.HISTORY; 188 final int bookmarkIdCol = cursor.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); 189 if (cursor.isNull(bookmarkIdCol)) { 190 // If this is a combined cursor, we may get a history item without a 191 // bookmark, in which case the bookmarks ID column value will be null. 192 info.bookmarkId = -1; 193 } else { 194 info.bookmarkId = cursor.getInt(bookmarkIdCol); 195 } 196 return info; 197 } 198 }); 199 200 mGrid.setOnItemClickListener(new AdapterView.OnItemClickListener() { 201 @Override 202 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 203 TopSitesGridItemView item = (TopSitesGridItemView) view; 204 205 // Decode "user-entered" URLs before loading them. 206 String url = StringUtils.decodeUserEnteredUrl(item.getUrl()); 207 int type = item.getType(); 208 209 // If the url is empty, the user can pin a site. 210 // If not, navigate to the page given by the url. 211 if (type != TopSites.TYPE_BLANK) { 212 if (mUrlOpenListener != null) { 213 final TelemetryContract.Method method; 214 if (type == TopSites.TYPE_SUGGESTED) { 215 method = TelemetryContract.Method.SUGGESTION; 216 } else { 217 method = TelemetryContract.Method.GRID_ITEM; 218 } 219 220 String extra = Integer.toString(position); 221 if (type == TopSites.TYPE_PINNED) { 222 extra += "-pinned"; 223 } 224 225 Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, extra); 226 227 mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.NO_READER_VIEW)); 228 } 229 } else { 230 if (mEditPinnedSiteListener != null) { 231 mEditPinnedSiteListener.onEditPinnedSite(position, ""); 232 } 233 } 234 } 235 }); 236 237 mGrid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { 238 @Override 239 public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 240 241 Cursor cursor = (Cursor) parent.getItemAtPosition(position); 242 243 TopSitesGridItemView item = (TopSitesGridItemView) view; 244 if (cursor == null || item.getType() == TopSites.TYPE_BLANK) { 245 mGrid.setContextMenuInfo(null); 246 return false; 247 } 248 249 TopSitesGridContextMenuInfo contextMenuInfo = new TopSitesGridContextMenuInfo(view, position, id); 250 updateContextMenuFromCursor(contextMenuInfo, cursor); 251 mGrid.setContextMenuInfo(contextMenuInfo); 252 return mGrid.showContextMenuForChild(mGrid); 253 } 254 255 /* 256 * Update the fields of a TopSitesGridContextMenuInfo object 257 * from a cursor. 258 * 259 * @param info context menu info object to be updated 260 * @param cursor used to update the context menu info object 261 */ 262 private void updateContextMenuFromCursor(TopSitesGridContextMenuInfo info, Cursor cursor) { 263 info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); 264 info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); 265 info.type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); 266 info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); 267 } 268 }); 269 270 registerForContextMenu(mList); 271 registerForContextMenu(mGrid); 272 } 273 274 @Override onDestroyView()275 public void onDestroyView() { 276 super.onDestroyView(); 277 278 // Discard any additional item clicks on the list as the 279 // panel is getting destroyed (see bugs 930160 & 1096958). 280 mList.setOnItemClickListener(null); 281 mGrid.setOnItemClickListener(null); 282 283 mList = null; 284 mGrid = null; 285 mListAdapter = null; 286 mGridAdapter = null; 287 } 288 289 @Override onActivityCreated(Bundle savedInstanceState)290 public void onActivityCreated(Bundle savedInstanceState) { 291 super.onActivityCreated(savedInstanceState); 292 293 final Activity activity = getActivity(); 294 295 // Setup the top sites grid adapter. 296 mGridAdapter = new TopSitesGridAdapter(activity, null); 297 mGrid.setAdapter(mGridAdapter); 298 299 // Setup the top sites list adapter. 300 mListAdapter = new VisitedAdapter(activity, null); 301 mList.setAdapter(mListAdapter); 302 303 // Create callbacks before the initial loader is started 304 mCursorLoaderCallbacks = new CursorLoaderCallbacks(); 305 mThumbnailsLoaderCallbacks = new ThumbnailsLoaderCallbacks(); 306 loadIfVisible(); 307 } 308 309 @Override onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo)310 public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { 311 if (menuInfo == null) { 312 return; 313 } 314 315 if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { 316 // Long pressed item was not a Top Sites GridView item. Superclass 317 // can handle this. 318 super.onCreateContextMenu(menu, view, menuInfo); 319 320 if (!Restrictions.isAllowed(view.getContext(), Restrictable.CLEAR_HISTORY)) { 321 menu.findItem(R.id.home_remove).setVisible(false); 322 } 323 324 return; 325 } 326 327 final Context context = view.getContext(); 328 329 // Long pressed item was a Top Sites GridView item, handle it. 330 MenuInflater inflater = new MenuInflater(context); 331 inflater.inflate(R.menu.home_contextmenu, menu); 332 333 // Hide unused menu items. 334 menu.findItem(R.id.home_edit_bookmark).setVisible(false); 335 336 menu.findItem(R.id.home_remove).setVisible(Restrictions.isAllowed(context, Restrictable.CLEAR_HISTORY)); 337 338 TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; 339 menu.setHeaderTitle(info.getDisplayTitle()); 340 341 if (info.type != TopSites.TYPE_BLANK) { 342 if (info.type == TopSites.TYPE_PINNED) { 343 menu.findItem(R.id.top_sites_pin).setVisible(false); 344 } else { 345 menu.findItem(R.id.top_sites_unpin).setVisible(false); 346 } 347 } else { 348 menu.findItem(R.id.home_open_new_tab).setVisible(false); 349 menu.findItem(R.id.home_open_private_tab).setVisible(false); 350 menu.findItem(R.id.top_sites_pin).setVisible(false); 351 menu.findItem(R.id.top_sites_unpin).setVisible(false); 352 } 353 354 if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) { 355 menu.findItem(R.id.home_share).setVisible(false); 356 } 357 358 if (!Restrictions.isAllowed(context, Restrictable.PRIVATE_BROWSING)) { 359 menu.findItem(R.id.home_open_private_tab).setVisible(false); 360 } 361 } 362 363 @Override onContextItemSelected(MenuItem item)364 public boolean onContextItemSelected(MenuItem item) { 365 if (super.onContextItemSelected(item)) { 366 // HomeFragment was able to handle to selected item. 367 return true; 368 } 369 370 ContextMenuInfo menuInfo = item.getMenuInfo(); 371 372 if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { 373 return false; 374 } 375 376 TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; 377 378 final int itemId = item.getItemId(); 379 final BrowserDB db = BrowserDB.from(getActivity()); 380 381 if (itemId == R.id.top_sites_pin) { 382 final String url = info.url; 383 final String title = info.title; 384 final int position = info.position; 385 final Context context = getActivity().getApplicationContext(); 386 387 ThreadUtils.postToBackgroundThread(new Runnable() { 388 @Override 389 public void run() { 390 db.pinSite(context.getContentResolver(), url, title, position); 391 } 392 }); 393 394 Telemetry.sendUIEvent(TelemetryContract.Event.PIN); 395 return true; 396 } 397 398 if (itemId == R.id.top_sites_unpin) { 399 final int position = info.position; 400 final Context context = getActivity().getApplicationContext(); 401 402 ThreadUtils.postToBackgroundThread(new Runnable() { 403 @Override 404 public void run() { 405 db.unpinSite(context.getContentResolver(), position); 406 } 407 }); 408 409 Telemetry.sendUIEvent(TelemetryContract.Event.UNPIN); 410 411 return true; 412 } 413 414 if (itemId == R.id.top_sites_edit) { 415 // Decode "user-entered" URLs before showing them. 416 mEditPinnedSiteListener.onEditPinnedSite(info.position, 417 StringUtils.decodeUserEnteredUrl(info.url)); 418 419 Telemetry.sendUIEvent(TelemetryContract.Event.EDIT); 420 return true; 421 } 422 423 return false; 424 } 425 426 @Override load()427 protected void load() { 428 getLoaderManager().initLoader(LOADER_ID_TOP_SITES, null, mCursorLoaderCallbacks); 429 430 // Since this is the primary fragment that loads whenever about:home is 431 // visited, we want to load it as quickly as possible. Heavy load on 432 // the Gecko thread can slow down the time it takes for thumbnails to 433 // appear, especially during startup (bug 897162). By minimizing the 434 // Gecko thread priority, we ensure that the UI appears quickly. The 435 // priority is reset to normal once thumbnails are loaded. 436 ThreadUtils.reduceGeckoPriority(PRIORITY_RESET_TIMEOUT); 437 } 438 439 /** 440 * Listener for editing pinned sites. 441 */ 442 private class EditPinnedSiteListener implements OnEditPinnedSiteListener, 443 OnSiteSelectedListener { 444 // Tag for the PinSiteDialog fragment. 445 private static final String TAG_PIN_SITE = "pin_site"; 446 447 // Position of the pin. 448 private int mPosition; 449 450 @Override onEditPinnedSite(int position, String searchTerm)451 public void onEditPinnedSite(int position, String searchTerm) { 452 final FragmentManager manager = getChildFragmentManager(); 453 PinSiteDialog dialog = (PinSiteDialog) manager.findFragmentByTag(TAG_PIN_SITE); 454 if (dialog == null) { 455 mPosition = position; 456 457 dialog = PinSiteDialog.newInstance(); 458 dialog.setOnSiteSelectedListener(this); 459 dialog.setSearchTerm(searchTerm); 460 dialog.show(manager, TAG_PIN_SITE); 461 } 462 } 463 464 @Override onSiteSelected(final String url, final String title)465 public void onSiteSelected(final String url, final String title) { 466 final int position = mPosition; 467 final Context context = getActivity().getApplicationContext(); 468 final BrowserDB db = BrowserDB.from(getActivity()); 469 ThreadUtils.postToBackgroundThread(new Runnable() { 470 @Override 471 public void run() { 472 db.pinSite(context.getContentResolver(), url, title, position); 473 } 474 }); 475 } 476 } 477 updateUiFromCursor(Cursor c)478 private void updateUiFromCursor(Cursor c) { 479 mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries); 480 } 481 updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails)482 private void updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails) { 483 if (mGridAdapter != null) { 484 mGridAdapter.updateThumbnails(thumbnails); 485 } 486 487 // Once thumbnails have finished loading, the UI is ready. Reset 488 // Gecko to normal priority. 489 ThreadUtils.resetGeckoPriority(); 490 } 491 492 private static class TopSitesLoader extends SimpleCursorLoader { 493 // Max number of search results. 494 private static final int SEARCH_LIMIT = 30; 495 private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_TOPSITES_LOADER_TIME_MS"; 496 private final BrowserDB mDB; 497 private final int mMaxGridEntries; 498 TopSitesLoader(Context context)499 public TopSitesLoader(Context context) { 500 super(context); 501 mMaxGridEntries = context.getResources().getInteger(R.integer.number_of_top_sites); 502 mDB = BrowserDB.from(context); 503 } 504 505 @Override loadCursor()506 public Cursor loadCursor() { 507 final long start = SystemClock.uptimeMillis(); 508 final Cursor cursor = mDB.getTopSites(getContext().getContentResolver(), mMaxGridEntries, SEARCH_LIMIT); 509 final long end = SystemClock.uptimeMillis(); 510 final long took = end - start; 511 Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE)); 512 return cursor; 513 } 514 } 515 516 private class VisitedAdapter extends CursorAdapter { VisitedAdapter(Context context, Cursor cursor)517 public VisitedAdapter(Context context, Cursor cursor) { 518 super(context, cursor, 0); 519 } 520 521 @Override getCount()522 public int getCount() { 523 return Math.max(0, super.getCount() - mMaxGridEntries); 524 } 525 526 @Override getItem(int position)527 public Object getItem(int position) { 528 return super.getItem(position + mMaxGridEntries); 529 } 530 531 /** 532 * We have to override default getItemId implementation, since for a given position, it returns 533 * value of the _id column. In our case _id is always 0 (see Combined view). 534 */ 535 @Override getItemId(int position)536 public long getItemId(int position) { 537 final int adjustedPosition = position + mMaxGridEntries; 538 final Cursor cursor = getCursor(); 539 540 cursor.moveToPosition(adjustedPosition); 541 return getItemIdForTopSitesCursor(cursor); 542 } 543 544 @Override bindView(View view, Context context, Cursor cursor)545 public void bindView(View view, Context context, Cursor cursor) { 546 final int position = cursor.getPosition(); 547 cursor.moveToPosition(position + mMaxGridEntries); 548 549 final TwoLinePageRow row = (TwoLinePageRow) view; 550 row.updateFromCursor(cursor); 551 } 552 553 @Override newView(Context context, Cursor cursor, ViewGroup parent)554 public View newView(Context context, Cursor cursor, ViewGroup parent) { 555 return LayoutInflater.from(context).inflate(R.layout.bookmark_item_row, parent, false); 556 } 557 } 558 559 public class TopSitesGridAdapter extends CursorAdapter { 560 private final BrowserDB mDB; 561 // Cache to store the thumbnails. 562 // Ensure that this is only accessed from the UI thread. 563 private Map<String, ThumbnailInfo> mThumbnailInfos; 564 TopSitesGridAdapter(Context context, Cursor cursor)565 public TopSitesGridAdapter(Context context, Cursor cursor) { 566 super(context, cursor, 0); 567 mDB = BrowserDB.from(context); 568 } 569 570 @Override getCount()571 public int getCount() { 572 return Math.min(mMaxGridEntries, super.getCount()); 573 } 574 575 @Override onContentChanged()576 protected void onContentChanged() { 577 // Don't do anything. We don't want to regenerate every time 578 // our database is updated. 579 return; 580 } 581 582 /** 583 * Update the thumbnails returned by the db. 584 * 585 * @param thumbnails A map of urls and their thumbnail bitmaps. 586 */ updateThumbnails(Map<String, ThumbnailInfo> thumbnails)587 public void updateThumbnails(Map<String, ThumbnailInfo> thumbnails) { 588 mThumbnailInfos = thumbnails; 589 590 final int count = mGrid.getChildCount(); 591 for (int i = 0; i < count; i++) { 592 TopSitesGridItemView gridItem = (TopSitesGridItemView) mGrid.getChildAt(i); 593 594 // All the views have already got their initial state at this point. 595 // This will force each view to load favicons for the missing 596 // thumbnails if necessary. 597 gridItem.markAsDirty(); 598 } 599 600 notifyDataSetChanged(); 601 } 602 603 /** 604 * We have to override default getItemId implementation, since for a given position, it returns 605 * value of the _id column. In our case _id is always 0 (see Combined view). 606 */ 607 @Override getItemId(int position)608 public long getItemId(int position) { 609 final Cursor cursor = getCursor(); 610 cursor.moveToPosition(position); 611 612 return getItemIdForTopSitesCursor(cursor); 613 } 614 615 @Override bindView(View bindView, Context context, Cursor cursor)616 public void bindView(View bindView, Context context, Cursor cursor) { 617 final String url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); 618 final String title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); 619 final int type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); 620 621 final TopSitesGridItemView view = (TopSitesGridItemView) bindView; 622 623 // If there is no url, then show "add bookmark". 624 if (type == TopSites.TYPE_BLANK) { 625 view.blankOut(); 626 return; 627 } 628 629 // Show the thumbnail, if any. 630 ThumbnailInfo thumbnail = (mThumbnailInfos != null ? mThumbnailInfos.get(url) : null); 631 632 // Debounce bindView calls to avoid redundant redraws and favicon 633 // fetches. 634 final boolean updated = view.updateState(title, url, type, thumbnail); 635 636 // Thumbnails are delivered late, so we can't short-circuit any 637 // sooner than this. But we can avoid a duplicate favicon 638 // fetch... 639 if (!updated) { 640 debug("bindView called twice for same values; short-circuiting."); 641 return; 642 } 643 644 // Make sure we query suggested images without the user-entered wrapper. 645 final String decodedUrl = StringUtils.decodeUserEnteredUrl(url); 646 647 // Suggested images have precedence over thumbnails, no need to wait 648 // for them to be loaded. See: CursorLoaderCallbacks.onLoadFinished() 649 final String imageUrl = mDB.getSuggestedImageUrlForUrl(decodedUrl); 650 if (!TextUtils.isEmpty(imageUrl)) { 651 final int bgColor = mDB.getSuggestedBackgroundColorForUrl(decodedUrl); 652 view.displayThumbnail(imageUrl, bgColor); 653 return; 654 } 655 656 // If thumbnails are still being loaded, don't try to load favicons 657 // just yet. If we sent in a thumbnail, we're done now. 658 if (mThumbnailInfos == null || thumbnail != null) { 659 return; 660 } 661 662 view.loadFavicon(url); 663 } 664 665 @Override newView(Context context, Cursor cursor, ViewGroup parent)666 public View newView(Context context, Cursor cursor, ViewGroup parent) { 667 return new TopSitesGridItemView(context); 668 } 669 } 670 671 private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> { 672 @Override onCreateLoader(int id, Bundle args)673 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 674 trace("Creating TopSitesLoader: " + id); 675 return new TopSitesLoader(getActivity()); 676 } 677 678 /** 679 * This method is called *twice* in some circumstances. 680 * 681 * If you try to avoid that through some kind of boolean flag, 682 * sometimes (e.g., returning to the activity) you'll *not* be called 683 * twice, and thus you'll never draw thumbnails. 684 * 685 * The root cause is TopSitesLoader.loadCursor being called twice. 686 * Why that is... dunno. 687 */ onLoadFinished(Loader<Cursor> loader, Cursor c)688 public void onLoadFinished(Loader<Cursor> loader, Cursor c) { 689 debug("onLoadFinished: " + c.getCount() + " rows."); 690 691 mListAdapter.swapCursor(c); 692 mGridAdapter.swapCursor(c); 693 updateUiFromCursor(c); 694 695 final int col = c.getColumnIndexOrThrow(TopSites.URL); 696 697 // Load the thumbnails. 698 // Even though the cursor we're given is supposed to be fresh, 699 // we getIcon a bad first value unless we reset its position. 700 // Using move(-1) and moveToNext() doesn't work correctly under 701 // rotation, so we use moveToFirst. 702 if (!c.moveToFirst()) { 703 return; 704 } 705 706 final ArrayList<String> urls = new ArrayList<String>(); 707 int i = 1; 708 do { 709 final String url = c.getString(col); 710 711 // Only try to fetch thumbnails for non-empty URLs that 712 // don't have an associated suggested image URL. 713 final GeckoProfile profile = GeckoProfile.get(getActivity()); 714 if (TextUtils.isEmpty(url) || BrowserDB.from(profile).hasSuggestedImageUrl(url)) { 715 continue; 716 } 717 718 urls.add(url); 719 } while (i++ < mMaxGridEntries && c.moveToNext()); 720 721 if (urls.isEmpty()) { 722 // Short-circuit empty results to the UI. 723 updateUiWithThumbnails(new HashMap<String, ThumbnailInfo>()); 724 return; 725 } 726 727 Bundle bundle = new Bundle(); 728 bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls); 729 getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks); 730 } 731 732 @Override onLoaderReset(Loader<Cursor> loader)733 public void onLoaderReset(Loader<Cursor> loader) { 734 if (mListAdapter != null) { 735 mListAdapter.swapCursor(null); 736 } 737 738 if (mGridAdapter != null) { 739 mGridAdapter.swapCursor(null); 740 } 741 } 742 } 743 744 static class ThumbnailInfo { 745 public final Bitmap bitmap; 746 public final String imageUrl; 747 public final int bgColor; 748 ThumbnailInfo(final Bitmap bitmap)749 public ThumbnailInfo(final Bitmap bitmap) { 750 this.bitmap = bitmap; 751 this.imageUrl = null; 752 this.bgColor = Color.TRANSPARENT; 753 } 754 ThumbnailInfo(final String imageUrl, final int bgColor)755 public ThumbnailInfo(final String imageUrl, final int bgColor) { 756 this.bitmap = null; 757 this.imageUrl = imageUrl; 758 this.bgColor = bgColor; 759 } 760 fromMetadata(final Map<String, Object> data)761 public static ThumbnailInfo fromMetadata(final Map<String, Object> data) { 762 if (data == null) { 763 return null; 764 } 765 766 final String imageUrl = (String) data.get(TILE_IMAGE_URL_COLUMN); 767 if (imageUrl == null) { 768 return null; 769 } 770 771 int bgColor = Color.WHITE; 772 final String colorString = (String) data.get(TILE_COLOR_COLUMN); 773 try { 774 bgColor = Color.parseColor(colorString); 775 } catch (Exception ex) { 776 } 777 778 return new ThumbnailInfo(imageUrl, bgColor); 779 } 780 } 781 782 /** 783 * An AsyncTaskLoader to load the thumbnails from a cursor. 784 */ 785 static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, ThumbnailInfo>> { 786 private final BrowserDB mDB; 787 private Map<String, ThumbnailInfo> mThumbnailInfos; 788 private final ArrayList<String> mUrls; 789 790 private static final List<String> COLUMNS; 791 static { 792 final ArrayList<String> tempColumns = new ArrayList<>(2); 793 tempColumns.add(TILE_IMAGE_URL_COLUMN); 794 tempColumns.add(TILE_COLOR_COLUMN); 795 COLUMNS = Collections.unmodifiableList(tempColumns); 796 } 797 ThumbnailsLoader(Context context, ArrayList<String> urls)798 public ThumbnailsLoader(Context context, ArrayList<String> urls) { 799 super(context); 800 mUrls = urls; 801 mDB = BrowserDB.from(context); 802 } 803 804 @Override loadInBackground()805 public Map<String, ThumbnailInfo> loadInBackground() { 806 final Map<String, ThumbnailInfo> thumbnails = new HashMap<String, ThumbnailInfo>(); 807 if (mUrls == null || mUrls.size() == 0) { 808 return thumbnails; 809 } 810 811 // We need to query metadata based on the URL without any refs, hence we create a new 812 // mapping and list of these URLs (we need to preserve the original URL for display purposes) 813 final Map<String, String> queryURLs = new HashMap<>(); 814 for (final String pageURL : mUrls) { 815 queryURLs.put(pageURL, StringUtils.stripRef(pageURL)); 816 } 817 818 // Query the DB for tile images. 819 final ContentResolver cr = getContext().getContentResolver(); 820 // Use the stripped URLs for querying the DB 821 final Map<String, Map<String, Object>> metadata = mDB.getURLMetadata().getForURLs(cr, queryURLs.values(), COLUMNS); 822 823 // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead. 824 final List<String> thumbnailUrls = new ArrayList<String>(); 825 for (final String pageURL : mUrls) { 826 final String queryURL = queryURLs.get(pageURL); 827 828 ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(queryURL)); 829 if (info == null) { 830 // If we didn't find metadata, we'll look for a thumbnail for this url. 831 thumbnailUrls.add(pageURL); 832 continue; 833 } 834 835 thumbnails.put(pageURL, info); 836 } 837 838 if (thumbnailUrls.size() == 0) { 839 return thumbnails; 840 } 841 842 // Query the DB for tile thumbnails. 843 final Cursor cursor = mDB.getThumbnailsForUrls(cr, thumbnailUrls); 844 if (cursor == null) { 845 return thumbnails; 846 } 847 848 try { 849 final int urlIndex = cursor.getColumnIndexOrThrow(Thumbnails.URL); 850 final int dataIndex = cursor.getColumnIndexOrThrow(Thumbnails.DATA); 851 852 while (cursor.moveToNext()) { 853 String url = cursor.getString(urlIndex); 854 855 // This should never be null, but if it is... 856 final byte[] b = cursor.getBlob(dataIndex); 857 if (b == null) { 858 continue; 859 } 860 861 final Bitmap bitmap = BitmapUtils.decodeByteArray(b); 862 863 // Our thumbnails are never null, so if we getIcon a null decoded 864 // bitmap, it's because we hit an OOM or some other disaster. 865 // Give up immediately rather than hammering on. 866 if (bitmap == null) { 867 Log.w(LOGTAG, "Aborting thumbnail load; decode failed."); 868 break; 869 } 870 871 thumbnails.put(url, new ThumbnailInfo(bitmap)); 872 } 873 } finally { 874 cursor.close(); 875 } 876 877 return thumbnails; 878 } 879 880 @Override deliverResult(Map<String, ThumbnailInfo> thumbnails)881 public void deliverResult(Map<String, ThumbnailInfo> thumbnails) { 882 if (isReset()) { 883 mThumbnailInfos = null; 884 return; 885 } 886 887 mThumbnailInfos = thumbnails; 888 889 if (isStarted()) { 890 super.deliverResult(thumbnails); 891 } 892 } 893 894 @Override onStartLoading()895 protected void onStartLoading() { 896 if (mThumbnailInfos != null) { 897 deliverResult(mThumbnailInfos); 898 } 899 900 if (takeContentChanged() || mThumbnailInfos == null) { 901 forceLoad(); 902 } 903 } 904 905 @Override onStopLoading()906 protected void onStopLoading() { 907 cancelLoad(); 908 } 909 910 @Override onCanceled(Map<String, ThumbnailInfo> thumbnails)911 public void onCanceled(Map<String, ThumbnailInfo> thumbnails) { 912 mThumbnailInfos = null; 913 } 914 915 @Override onReset()916 protected void onReset() { 917 super.onReset(); 918 919 // Ensure the loader is stopped. 920 onStopLoading(); 921 922 mThumbnailInfos = null; 923 } 924 } 925 926 /** 927 * Loader callbacks for the thumbnails on TopSitesGridView. 928 */ 929 private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, ThumbnailInfo>> { 930 @Override onCreateLoader(int id, Bundle args)931 public Loader<Map<String, ThumbnailInfo>> onCreateLoader(int id, Bundle args) { 932 return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY)); 933 } 934 935 @Override onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails)936 public void onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails) { 937 updateUiWithThumbnails(thumbnails); 938 } 939 940 @Override onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader)941 public void onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader) { 942 if (mGridAdapter != null) { 943 mGridAdapter.updateThumbnails(null); 944 } 945 } 946 } 947 948 /** 949 * We are trying to return stable IDs so that Android can recycle views appropriately: 950 * - If we have a history ID then we return it 951 * - If we only have a bookmark ID then we negate it and return it. We negate it in order 952 * to avoid clashing/conflicting with history IDs. 953 * 954 * @param cursorInPosition Cursor already moved to position for which we're getting a stable ID 955 * @return Stable ID for a given cursor 956 */ getItemIdForTopSitesCursor(final Cursor cursorInPosition)957 private static long getItemIdForTopSitesCursor(final Cursor cursorInPosition) { 958 final int historyIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.HISTORY_ID); 959 final long historyId = cursorInPosition.getLong(historyIdCol); 960 if (historyId != 0) { 961 return historyId; 962 } 963 964 final int bookmarkIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); 965 final long bookmarkId = cursorInPosition.getLong(bookmarkIdCol); 966 return -1 * bookmarkId; 967 } 968 } 969