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