/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.home; import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN; import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Future; import org.mozilla.gecko.GeckoProfile; import org.mozilla.gecko.R; import org.mozilla.gecko.Telemetry; import org.mozilla.gecko.TelemetryContract; import org.mozilla.gecko.db.BrowserContract.Thumbnails; import org.mozilla.gecko.db.BrowserContract.TopSites; import org.mozilla.gecko.db.BrowserDB; import org.mozilla.gecko.gfx.BitmapUtils; import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType; import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; import org.mozilla.gecko.home.PinSiteDialog.OnSiteSelectedListener; import org.mozilla.gecko.home.TopSitesGridView.OnEditPinnedSiteListener; import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo; import org.mozilla.gecko.icons.IconCallback; import org.mozilla.gecko.icons.IconResponse; import org.mozilla.gecko.icons.Icons; import org.mozilla.gecko.restrictions.Restrictable; import org.mozilla.gecko.restrictions.Restrictions; import org.mozilla.gecko.util.StringUtils; import org.mozilla.gecko.util.ThreadUtils; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Color; import android.os.Bundle; import android.os.SystemClock; import android.support.v4.app.FragmentManager; import android.support.v4.app.LoaderManager.LoaderCallbacks; import android.support.v4.content.AsyncTaskLoader; import android.support.v4.content.Loader; import android.support.v4.widget.CursorAdapter; import android.text.TextUtils; import android.util.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.LayoutInflater; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ListView; /** * Fragment that displays frecency search results in a ListView. */ public class TopSitesPanel extends HomeFragment { // Logging tag name private static final String LOGTAG = "GeckoTopSitesPanel"; // Cursor loader ID for the top sites private static final int LOADER_ID_TOP_SITES = 0; // Loader ID for thumbnails private static final int LOADER_ID_THUMBNAILS = 1; // Key for thumbnail urls private static final String THUMBNAILS_URLS_KEY = "urls"; // Adapter for the list of top sites private VisitedAdapter mListAdapter; // Adapter for the grid of top sites private TopSitesGridAdapter mGridAdapter; // List of top sites private HomeListView mList; // Grid of top sites private TopSitesGridView mGrid; // Callbacks used for the search and favicon cursor loaders private CursorLoaderCallbacks mCursorLoaderCallbacks; // Callback for thumbnail loader private ThumbnailsLoaderCallbacks mThumbnailsLoaderCallbacks; // Listener for editing pinned sites. private EditPinnedSiteListener mEditPinnedSiteListener; // Max number of entries shown in the grid from the cursor. private int mMaxGridEntries; // Time in ms until the Gecko thread is reset to normal priority. private static final long PRIORITY_RESET_TIMEOUT = 10000; public static TopSitesPanel newInstance() { return new TopSitesPanel(); } private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); private static void debug(final String message) { if (logDebug) { Log.d(LOGTAG, message); } } private static void trace(final String message) { if (logVerbose) { Log.v(LOGTAG, message); } } @Override public void onAttach(Activity activity) { super.onAttach(activity); mMaxGridEntries = activity.getResources().getInteger(R.integer.number_of_top_sites); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.home_top_sites_panel, container, false); mList = (HomeListView) view.findViewById(R.id.list); mGrid = new TopSitesGridView(getActivity()); mList.addHeaderView(mGrid); return view; } @Override public void onViewCreated(View view, Bundle savedInstanceState) { mEditPinnedSiteListener = new EditPinnedSiteListener(); mList.setTag(HomePager.LIST_TAG_TOP_SITES); mList.setHeaderDividersEnabled(false); mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { final ListView list = (ListView) parent; final int headerCount = list.getHeaderViewsCount(); if (position < headerCount) { // The click is on a header, don't do anything. return; } // Absolute position for the adapter. position += (mGridAdapter.getCount() - headerCount); final Cursor c = mListAdapter.getCursor(); if (c == null || !c.moveToPosition(position)) { return; } final String url = c.getString(c.getColumnIndexOrThrow(TopSites.URL)); Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "top_sites"); // This item is a TwoLinePageRow, so we allow switch-to-tab. mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); } }); mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { @Override public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); info.itemType = RemoveItemType.HISTORY; final int bookmarkIdCol = cursor.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); if (cursor.isNull(bookmarkIdCol)) { // If this is a combined cursor, we may get a history item without a // bookmark, in which case the bookmarks ID column value will be null. info.bookmarkId = -1; } else { info.bookmarkId = cursor.getInt(bookmarkIdCol); } return info; } }); mGrid.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { TopSitesGridItemView item = (TopSitesGridItemView) view; // Decode "user-entered" URLs before loading them. String url = StringUtils.decodeUserEnteredUrl(item.getUrl()); int type = item.getType(); // If the url is empty, the user can pin a site. // If not, navigate to the page given by the url. if (type != TopSites.TYPE_BLANK) { if (mUrlOpenListener != null) { final TelemetryContract.Method method; if (type == TopSites.TYPE_SUGGESTED) { method = TelemetryContract.Method.SUGGESTION; } else { method = TelemetryContract.Method.GRID_ITEM; } String extra = Integer.toString(position); if (type == TopSites.TYPE_PINNED) { extra += "-pinned"; } Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, extra); mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.NO_READER_VIEW)); } } else { if (mEditPinnedSiteListener != null) { mEditPinnedSiteListener.onEditPinnedSite(position, ""); } } } }); mGrid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { @Override public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { Cursor cursor = (Cursor) parent.getItemAtPosition(position); TopSitesGridItemView item = (TopSitesGridItemView) view; if (cursor == null || item.getType() == TopSites.TYPE_BLANK) { mGrid.setContextMenuInfo(null); return false; } TopSitesGridContextMenuInfo contextMenuInfo = new TopSitesGridContextMenuInfo(view, position, id); updateContextMenuFromCursor(contextMenuInfo, cursor); mGrid.setContextMenuInfo(contextMenuInfo); return mGrid.showContextMenuForChild(mGrid); } /* * Update the fields of a TopSitesGridContextMenuInfo object * from a cursor. * * @param info context menu info object to be updated * @param cursor used to update the context menu info object */ private void updateContextMenuFromCursor(TopSitesGridContextMenuInfo info, Cursor cursor) { info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); info.type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); } }); registerForContextMenu(mList); registerForContextMenu(mGrid); } @Override public void onDestroyView() { super.onDestroyView(); // Discard any additional item clicks on the list as the // panel is getting destroyed (see bugs 930160 & 1096958). mList.setOnItemClickListener(null); mGrid.setOnItemClickListener(null); mList = null; mGrid = null; mListAdapter = null; mGridAdapter = null; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); final Activity activity = getActivity(); // Setup the top sites grid adapter. mGridAdapter = new TopSitesGridAdapter(activity, null); mGrid.setAdapter(mGridAdapter); // Setup the top sites list adapter. mListAdapter = new VisitedAdapter(activity, null); mList.setAdapter(mListAdapter); // Create callbacks before the initial loader is started mCursorLoaderCallbacks = new CursorLoaderCallbacks(); mThumbnailsLoaderCallbacks = new ThumbnailsLoaderCallbacks(); loadIfVisible(); } @Override public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { if (menuInfo == null) { return; } if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { // Long pressed item was not a Top Sites GridView item. Superclass // can handle this. super.onCreateContextMenu(menu, view, menuInfo); if (!Restrictions.isAllowed(view.getContext(), Restrictable.CLEAR_HISTORY)) { menu.findItem(R.id.home_remove).setVisible(false); } return; } final Context context = view.getContext(); // Long pressed item was a Top Sites GridView item, handle it. MenuInflater inflater = new MenuInflater(context); inflater.inflate(R.menu.home_contextmenu, menu); // Hide unused menu items. menu.findItem(R.id.home_edit_bookmark).setVisible(false); menu.findItem(R.id.home_remove).setVisible(Restrictions.isAllowed(context, Restrictable.CLEAR_HISTORY)); TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; menu.setHeaderTitle(info.getDisplayTitle()); if (info.type != TopSites.TYPE_BLANK) { if (info.type == TopSites.TYPE_PINNED) { menu.findItem(R.id.top_sites_pin).setVisible(false); } else { menu.findItem(R.id.top_sites_unpin).setVisible(false); } } else { menu.findItem(R.id.home_open_new_tab).setVisible(false); menu.findItem(R.id.home_open_private_tab).setVisible(false); menu.findItem(R.id.top_sites_pin).setVisible(false); menu.findItem(R.id.top_sites_unpin).setVisible(false); } if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) { menu.findItem(R.id.home_share).setVisible(false); } if (!Restrictions.isAllowed(context, Restrictable.PRIVATE_BROWSING)) { menu.findItem(R.id.home_open_private_tab).setVisible(false); } } @Override public boolean onContextItemSelected(MenuItem item) { if (super.onContextItemSelected(item)) { // HomeFragment was able to handle to selected item. return true; } ContextMenuInfo menuInfo = item.getMenuInfo(); if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { return false; } TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; final int itemId = item.getItemId(); final BrowserDB db = BrowserDB.from(getActivity()); if (itemId == R.id.top_sites_pin) { final String url = info.url; final String title = info.title; final int position = info.position; final Context context = getActivity().getApplicationContext(); ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { db.pinSite(context.getContentResolver(), url, title, position); } }); Telemetry.sendUIEvent(TelemetryContract.Event.PIN); return true; } if (itemId == R.id.top_sites_unpin) { final int position = info.position; final Context context = getActivity().getApplicationContext(); ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { db.unpinSite(context.getContentResolver(), position); } }); Telemetry.sendUIEvent(TelemetryContract.Event.UNPIN); return true; } if (itemId == R.id.top_sites_edit) { // Decode "user-entered" URLs before showing them. mEditPinnedSiteListener.onEditPinnedSite(info.position, StringUtils.decodeUserEnteredUrl(info.url)); Telemetry.sendUIEvent(TelemetryContract.Event.EDIT); return true; } return false; } @Override protected void load() { getLoaderManager().initLoader(LOADER_ID_TOP_SITES, null, mCursorLoaderCallbacks); // Since this is the primary fragment that loads whenever about:home is // visited, we want to load it as quickly as possible. Heavy load on // the Gecko thread can slow down the time it takes for thumbnails to // appear, especially during startup (bug 897162). By minimizing the // Gecko thread priority, we ensure that the UI appears quickly. The // priority is reset to normal once thumbnails are loaded. ThreadUtils.reduceGeckoPriority(PRIORITY_RESET_TIMEOUT); } /** * Listener for editing pinned sites. */ private class EditPinnedSiteListener implements OnEditPinnedSiteListener, OnSiteSelectedListener { // Tag for the PinSiteDialog fragment. private static final String TAG_PIN_SITE = "pin_site"; // Position of the pin. private int mPosition; @Override public void onEditPinnedSite(int position, String searchTerm) { final FragmentManager manager = getChildFragmentManager(); PinSiteDialog dialog = (PinSiteDialog) manager.findFragmentByTag(TAG_PIN_SITE); if (dialog == null) { mPosition = position; dialog = PinSiteDialog.newInstance(); dialog.setOnSiteSelectedListener(this); dialog.setSearchTerm(searchTerm); dialog.show(manager, TAG_PIN_SITE); } } @Override public void onSiteSelected(final String url, final String title) { final int position = mPosition; final Context context = getActivity().getApplicationContext(); final BrowserDB db = BrowserDB.from(getActivity()); ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { db.pinSite(context.getContentResolver(), url, title, position); } }); } } private void updateUiFromCursor(Cursor c) { mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries); } private void updateUiWithThumbnails(Map thumbnails) { if (mGridAdapter != null) { mGridAdapter.updateThumbnails(thumbnails); } // Once thumbnails have finished loading, the UI is ready. Reset // Gecko to normal priority. ThreadUtils.resetGeckoPriority(); } private static class TopSitesLoader extends SimpleCursorLoader { // Max number of search results. private static final int SEARCH_LIMIT = 30; private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_TOPSITES_LOADER_TIME_MS"; private final BrowserDB mDB; private final int mMaxGridEntries; public TopSitesLoader(Context context) { super(context); mMaxGridEntries = context.getResources().getInteger(R.integer.number_of_top_sites); mDB = BrowserDB.from(context); } @Override public Cursor loadCursor() { final long start = SystemClock.uptimeMillis(); final Cursor cursor = mDB.getTopSites(getContext().getContentResolver(), mMaxGridEntries, SEARCH_LIMIT); final long end = SystemClock.uptimeMillis(); final long took = end - start; Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE)); return cursor; } } private class VisitedAdapter extends CursorAdapter { public VisitedAdapter(Context context, Cursor cursor) { super(context, cursor, 0); } @Override public int getCount() { return Math.max(0, super.getCount() - mMaxGridEntries); } @Override public Object getItem(int position) { return super.getItem(position + mMaxGridEntries); } /** * We have to override default getItemId implementation, since for a given position, it returns * value of the _id column. In our case _id is always 0 (see Combined view). */ @Override public long getItemId(int position) { final int adjustedPosition = position + mMaxGridEntries; final Cursor cursor = getCursor(); cursor.moveToPosition(adjustedPosition); return getItemIdForTopSitesCursor(cursor); } @Override public void bindView(View view, Context context, Cursor cursor) { final int position = cursor.getPosition(); cursor.moveToPosition(position + mMaxGridEntries); final TwoLinePageRow row = (TwoLinePageRow) view; row.updateFromCursor(cursor); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { return LayoutInflater.from(context).inflate(R.layout.bookmark_item_row, parent, false); } } public class TopSitesGridAdapter extends CursorAdapter { private final BrowserDB mDB; // Cache to store the thumbnails. // Ensure that this is only accessed from the UI thread. private Map mThumbnailInfos; public TopSitesGridAdapter(Context context, Cursor cursor) { super(context, cursor, 0); mDB = BrowserDB.from(context); } @Override public int getCount() { return Math.min(mMaxGridEntries, super.getCount()); } @Override protected void onContentChanged() { // Don't do anything. We don't want to regenerate every time // our database is updated. return; } /** * Update the thumbnails returned by the db. * * @param thumbnails A map of urls and their thumbnail bitmaps. */ public void updateThumbnails(Map thumbnails) { mThumbnailInfos = thumbnails; final int count = mGrid.getChildCount(); for (int i = 0; i < count; i++) { TopSitesGridItemView gridItem = (TopSitesGridItemView) mGrid.getChildAt(i); // All the views have already got their initial state at this point. // This will force each view to load favicons for the missing // thumbnails if necessary. gridItem.markAsDirty(); } notifyDataSetChanged(); } /** * We have to override default getItemId implementation, since for a given position, it returns * value of the _id column. In our case _id is always 0 (see Combined view). */ @Override public long getItemId(int position) { final Cursor cursor = getCursor(); cursor.moveToPosition(position); return getItemIdForTopSitesCursor(cursor); } @Override public void bindView(View bindView, Context context, Cursor cursor) { final String url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); final String title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); final int type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); final TopSitesGridItemView view = (TopSitesGridItemView) bindView; // If there is no url, then show "add bookmark". if (type == TopSites.TYPE_BLANK) { view.blankOut(); return; } // Show the thumbnail, if any. ThumbnailInfo thumbnail = (mThumbnailInfos != null ? mThumbnailInfos.get(url) : null); // Debounce bindView calls to avoid redundant redraws and favicon // fetches. final boolean updated = view.updateState(title, url, type, thumbnail); // Thumbnails are delivered late, so we can't short-circuit any // sooner than this. But we can avoid a duplicate favicon // fetch... if (!updated) { debug("bindView called twice for same values; short-circuiting."); return; } // Make sure we query suggested images without the user-entered wrapper. final String decodedUrl = StringUtils.decodeUserEnteredUrl(url); // Suggested images have precedence over thumbnails, no need to wait // for them to be loaded. See: CursorLoaderCallbacks.onLoadFinished() final String imageUrl = mDB.getSuggestedImageUrlForUrl(decodedUrl); if (!TextUtils.isEmpty(imageUrl)) { final int bgColor = mDB.getSuggestedBackgroundColorForUrl(decodedUrl); view.displayThumbnail(imageUrl, bgColor); return; } // If thumbnails are still being loaded, don't try to load favicons // just yet. If we sent in a thumbnail, we're done now. if (mThumbnailInfos == null || thumbnail != null) { return; } view.loadFavicon(url); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { return new TopSitesGridItemView(context); } } private class CursorLoaderCallbacks implements LoaderCallbacks { @Override public Loader onCreateLoader(int id, Bundle args) { trace("Creating TopSitesLoader: " + id); return new TopSitesLoader(getActivity()); } /** * This method is called *twice* in some circumstances. * * If you try to avoid that through some kind of boolean flag, * sometimes (e.g., returning to the activity) you'll *not* be called * twice, and thus you'll never draw thumbnails. * * The root cause is TopSitesLoader.loadCursor being called twice. * Why that is... dunno. */ public void onLoadFinished(Loader loader, Cursor c) { debug("onLoadFinished: " + c.getCount() + " rows."); mListAdapter.swapCursor(c); mGridAdapter.swapCursor(c); updateUiFromCursor(c); final int col = c.getColumnIndexOrThrow(TopSites.URL); // Load the thumbnails. // Even though the cursor we're given is supposed to be fresh, // we getIcon a bad first value unless we reset its position. // Using move(-1) and moveToNext() doesn't work correctly under // rotation, so we use moveToFirst. if (!c.moveToFirst()) { return; } final ArrayList urls = new ArrayList(); int i = 1; do { final String url = c.getString(col); // Only try to fetch thumbnails for non-empty URLs that // don't have an associated suggested image URL. final GeckoProfile profile = GeckoProfile.get(getActivity()); if (TextUtils.isEmpty(url) || BrowserDB.from(profile).hasSuggestedImageUrl(url)) { continue; } urls.add(url); } while (i++ < mMaxGridEntries && c.moveToNext()); if (urls.isEmpty()) { // Short-circuit empty results to the UI. updateUiWithThumbnails(new HashMap()); return; } Bundle bundle = new Bundle(); bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls); getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks); } @Override public void onLoaderReset(Loader loader) { if (mListAdapter != null) { mListAdapter.swapCursor(null); } if (mGridAdapter != null) { mGridAdapter.swapCursor(null); } } } static class ThumbnailInfo { public final Bitmap bitmap; public final String imageUrl; public final int bgColor; public ThumbnailInfo(final Bitmap bitmap) { this.bitmap = bitmap; this.imageUrl = null; this.bgColor = Color.TRANSPARENT; } public ThumbnailInfo(final String imageUrl, final int bgColor) { this.bitmap = null; this.imageUrl = imageUrl; this.bgColor = bgColor; } public static ThumbnailInfo fromMetadata(final Map data) { if (data == null) { return null; } final String imageUrl = (String) data.get(TILE_IMAGE_URL_COLUMN); if (imageUrl == null) { return null; } int bgColor = Color.WHITE; final String colorString = (String) data.get(TILE_COLOR_COLUMN); try { bgColor = Color.parseColor(colorString); } catch (Exception ex) { } return new ThumbnailInfo(imageUrl, bgColor); } } /** * An AsyncTaskLoader to load the thumbnails from a cursor. */ static class ThumbnailsLoader extends AsyncTaskLoader> { private final BrowserDB mDB; private Map mThumbnailInfos; private final ArrayList mUrls; private static final List COLUMNS; static { final ArrayList tempColumns = new ArrayList<>(2); tempColumns.add(TILE_IMAGE_URL_COLUMN); tempColumns.add(TILE_COLOR_COLUMN); COLUMNS = Collections.unmodifiableList(tempColumns); } public ThumbnailsLoader(Context context, ArrayList urls) { super(context); mUrls = urls; mDB = BrowserDB.from(context); } @Override public Map loadInBackground() { final Map thumbnails = new HashMap(); if (mUrls == null || mUrls.size() == 0) { return thumbnails; } // We need to query metadata based on the URL without any refs, hence we create a new // mapping and list of these URLs (we need to preserve the original URL for display purposes) final Map queryURLs = new HashMap<>(); for (final String pageURL : mUrls) { queryURLs.put(pageURL, StringUtils.stripRef(pageURL)); } // Query the DB for tile images. final ContentResolver cr = getContext().getContentResolver(); // Use the stripped URLs for querying the DB final Map> metadata = mDB.getURLMetadata().getForURLs(cr, queryURLs.values(), COLUMNS); // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead. final List thumbnailUrls = new ArrayList(); for (final String pageURL : mUrls) { final String queryURL = queryURLs.get(pageURL); ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(queryURL)); if (info == null) { // If we didn't find metadata, we'll look for a thumbnail for this url. thumbnailUrls.add(pageURL); continue; } thumbnails.put(pageURL, info); } if (thumbnailUrls.size() == 0) { return thumbnails; } // Query the DB for tile thumbnails. final Cursor cursor = mDB.getThumbnailsForUrls(cr, thumbnailUrls); if (cursor == null) { return thumbnails; } try { final int urlIndex = cursor.getColumnIndexOrThrow(Thumbnails.URL); final int dataIndex = cursor.getColumnIndexOrThrow(Thumbnails.DATA); while (cursor.moveToNext()) { String url = cursor.getString(urlIndex); // This should never be null, but if it is... final byte[] b = cursor.getBlob(dataIndex); if (b == null) { continue; } final Bitmap bitmap = BitmapUtils.decodeByteArray(b); // Our thumbnails are never null, so if we getIcon a null decoded // bitmap, it's because we hit an OOM or some other disaster. // Give up immediately rather than hammering on. if (bitmap == null) { Log.w(LOGTAG, "Aborting thumbnail load; decode failed."); break; } thumbnails.put(url, new ThumbnailInfo(bitmap)); } } finally { cursor.close(); } return thumbnails; } @Override public void deliverResult(Map thumbnails) { if (isReset()) { mThumbnailInfos = null; return; } mThumbnailInfos = thumbnails; if (isStarted()) { super.deliverResult(thumbnails); } } @Override protected void onStartLoading() { if (mThumbnailInfos != null) { deliverResult(mThumbnailInfos); } if (takeContentChanged() || mThumbnailInfos == null) { forceLoad(); } } @Override protected void onStopLoading() { cancelLoad(); } @Override public void onCanceled(Map thumbnails) { mThumbnailInfos = null; } @Override protected void onReset() { super.onReset(); // Ensure the loader is stopped. onStopLoading(); mThumbnailInfos = null; } } /** * Loader callbacks for the thumbnails on TopSitesGridView. */ private class ThumbnailsLoaderCallbacks implements LoaderCallbacks> { @Override public Loader> onCreateLoader(int id, Bundle args) { return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY)); } @Override public void onLoadFinished(Loader> loader, Map thumbnails) { updateUiWithThumbnails(thumbnails); } @Override public void onLoaderReset(Loader> loader) { if (mGridAdapter != null) { mGridAdapter.updateThumbnails(null); } } } /** * We are trying to return stable IDs so that Android can recycle views appropriately: * - If we have a history ID then we return it * - If we only have a bookmark ID then we negate it and return it. We negate it in order * to avoid clashing/conflicting with history IDs. * * @param cursorInPosition Cursor already moved to position for which we're getting a stable ID * @return Stable ID for a given cursor */ private static long getItemIdForTopSitesCursor(final Cursor cursorInPosition) { final int historyIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.HISTORY_ID); final long historyId = cursorInPosition.getLong(historyIdCol); if (historyId != 0) { return historyId; } final int bookmarkIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); final long bookmarkId = cursorInPosition.getLong(bookmarkIdCol); return -1 * bookmarkId; } }