Bug 1014712 - Store ms tile image information for showing on about:home. r=rnewman,lucasr
authorWes Johnston <wjohnston@mozilla.com>
Tue, 10 Jun 2014 06:22:47 -0700
changeset 215104 0814bb0f08d097ee4eb0bd7b2e5794357f76c766
parent 215001 e9d78c3d2eb584b74266b6579cbb1e5ee45005fd
child 215105 6ee3c3ba17b589a7dd9faa33bdeffdf3823d158d
push id3857
push userraliiev@mozilla.com
push dateTue, 02 Sep 2014 16:39:23 +0000
treeherdermozilla-beta@5638b907b505 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, lucasr
bugs1014712
milestone33.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1014712 - Store ms tile image information for showing on about:home. r=rnewman,lucasr
mobile/android/base/Tab.java
mobile/android/base/Tabs.java
mobile/android/base/db/AbstractTransactionalProvider.java
mobile/android/base/db/BaseTable.java
mobile/android/base/db/BrowserDatabaseHelper.java
mobile/android/base/db/BrowserProvider.java
mobile/android/base/db/DBUtils.java
mobile/android/base/db/LocalBrowserDB.java
mobile/android/base/db/SharedBrowserDatabaseProvider.java
mobile/android/base/db/Table.java
mobile/android/base/db/URLMetadata.java
mobile/android/base/db/URLMetadataTable.java
mobile/android/base/gfx/BitmapUtils.java
mobile/android/base/home/TopSitesGridItemView.java
mobile/android/base/home/TopSitesPanel.java
mobile/android/base/moz.build
mobile/android/base/util/ThreadUtils.java
mobile/android/chrome/content/browser.js
toolkit/components/telemetry/Histograms.json
--- a/mobile/android/base/Tab.java
+++ b/mobile/android/base/Tab.java
@@ -3,23 +3,25 @@
  * 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;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.db.BrowserContract.Bookmarks;
 import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.URLMetadata;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.drawable.BitmapDrawable;
@@ -298,16 +300,31 @@ public class Tab {
         else
             setErrorType(ErrorType.NONE);
     }
 
     public void setErrorType(ErrorType type) {
         mErrorType = type;
     }
 
+    public void setMetadata(JSONObject metadata) {
+        if (metadata == null) {
+            return;
+        }
+
+        final ContentResolver cr = mAppContext.getContentResolver();
+        final Map<String, Object> data = URLMetadata.fromJSON(metadata);
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                URLMetadata.save(cr, mUrl, data);
+            }
+        });
+    }
+
     public ErrorType getErrorType() {
         return mErrorType;
     }
 
     public void setContentType(String contentType) {
         mContentType = (contentType == null) ? "" : contentType;
     }
 
--- a/mobile/android/base/Tabs.java
+++ b/mobile/android/base/Tabs.java
@@ -491,16 +491,17 @@ public class Tabs implements GeckoEventL
                 String backgroundColor = message.getString("bgColor");
                 if (backgroundColor != null) {
                     tab.setBackgroundColor(backgroundColor);
                 } else {
                     // Default to white if no color is given
                     tab.setBackgroundColor(Color.WHITE);
                 }
                 tab.setErrorType(message.optString("errorType"));
+                tab.setMetadata(message.optJSONObject("metadata"));
                 notifyListeners(tab, Tabs.TabEvents.LOADED);
             } else if (event.equals("DOMTitleChanged")) {
                 tab.updateTitle(message.getString("title"));
             } else if (event.equals("Link:Favicon")) {
                 tab.updateFaviconURL(message.getString("href"), message.getInt("size"));
                 notifyListeners(tab, TabEvents.LINK_FAVICON);
             } else if (event.equals("Link:Feed")) {
                 tab.setHasFeeds(true);
--- a/mobile/android/base/db/AbstractTransactionalProvider.java
+++ b/mobile/android/base/db/AbstractTransactionalProvider.java
@@ -1,17 +1,16 @@
 /* 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.db;
 
 import android.content.ContentProvider;
 import android.content.ContentValues;
-import android.database.Cursor;
 import android.database.SQLException;
 import android.database.sqlite.SQLiteDatabase;
 import android.net.Uri;
 import android.os.Build;
 import android.text.TextUtils;
 import android.util.Log;
 
 /**
@@ -93,30 +92,16 @@ public abstract class AbstractTransactio
      * Return true if OS version and database parallelism support indicates
      * that this provider should bundle writes into transactions.
      */
     @SuppressWarnings("static-method")
     protected boolean shouldUseTransactions() {
         return Build.VERSION.SDK_INT >= 11;
     }
 
-    protected static String computeSQLInClause(int items, String field) {
-        final StringBuilder builder = new StringBuilder(field);
-        builder.append(" IN (");
-        int i = 0;
-        for (; i < items - 1; ++i) {
-            builder.append("?, ");
-        }
-        if (i < items) {
-            builder.append("?");
-        }
-        builder.append(")");
-        return builder.toString();
-    }
-
     private boolean isInBatch() {
         final Boolean isInBatch = isInBatchOperation.get();
         if (isInBatch == null) {
             return false;
         }
         return isInBatch.booleanValue();
     }
 
@@ -186,36 +171,16 @@ public abstract class AbstractTransactio
     }
 
     protected void endBatch(final SQLiteDatabase db) {
         trace("Ending batch.");
         db.endTransaction();
         isInBatchOperation.set(Boolean.FALSE);
     }
 
-    /**
-     * Turn a single-column cursor of longs into a single SQL "IN" clause.
-     * We can do this without using selection arguments because Long isn't
-     * vulnerable to injection.
-     */
-    protected static String computeSQLInClauseFromLongs(final Cursor cursor, String field) {
-        final StringBuilder builder = new StringBuilder(field);
-        builder.append(" IN (");
-        final int commaLimit = cursor.getCount() - 1;
-        int i = 0;
-        while (cursor.moveToNext()) {
-            builder.append(cursor.getLong(0));
-            if (i++ < commaLimit) {
-                builder.append(", ");
-            }
-        }
-        builder.append(")");
-        return builder.toString();
-    }
-
     @Override
     public int delete(Uri uri, String selection, String[] selectionArgs) {
         trace("Calling delete on URI: " + uri + ", " + selection + ", " + selectionArgs);
 
         final SQLiteDatabase db = getWritableDatabase(uri);
         int deleted = 0;
 
         try {
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/db/BaseTable.java
@@ -0,0 +1,72 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.Log;
+
+// BaseTable provides a basic implementation of a Table for tables that don't require advanced operations during
+// insert, delete, update, or query operations. Implementors must still provide onCreate and onUpgrade operations.
+public abstract class BaseTable implements Table {
+    private static final String LOGTAG = "GeckoBaseTable";
+
+    private static final boolean DEBUG = false;
+
+    protected static void log(String msg) {
+        if (DEBUG) {
+            Log.i(LOGTAG, msg);
+        }
+    }
+
+    // Table implementation
+    @Override
+    public Table.ContentProviderInfo[] getContentProviderInfo() {
+        return new Table.ContentProviderInfo[0];
+    }
+
+    // Table implementation
+    @Override
+    public abstract void onCreate(SQLiteDatabase db);
+
+    // Table implementation
+    @Override
+    public abstract void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion);
+
+    // Returns the name of the table to modify/query
+    protected abstract String getTable();
+
+    // Table implementation
+    @Override
+    public Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] columns, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit) {
+        Cursor c = db.query(getTable(), columns, selection, selectionArgs, groupBy, null, sortOrder, limit);
+        log("query " + columns + " in " + selection + " = " + c);
+        return c;
+    }
+
+    @Override
+    public int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs) {
+        int updated = db.updateWithOnConflict(getTable(), values, selection, selectionArgs, SQLiteDatabase.CONFLICT_REPLACE);
+        log("update " + values + " in " + selection + " = " + updated);
+        return updated;
+    }
+
+    @Override
+    public long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values) {
+        long inserted = db.insertOrThrow(getTable(), null, values);
+        log("insert " + values + " = " + inserted);
+        return inserted;
+    }
+
+    @Override
+    public int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs) {
+        int deleted = db.delete(getTable(), selection, selectionArgs);
+        log("delete " + selection + " = " + deleted);
+        return deleted;
+    }
+};
--- a/mobile/android/base/db/BrowserDatabaseHelper.java
+++ b/mobile/android/base/db/BrowserDatabaseHelper.java
@@ -30,17 +30,17 @@ import android.database.sqlite.SQLiteOpe
 import android.net.Uri;
 import android.os.Build;
 import android.util.Log;
 
 
 final class BrowserDatabaseHelper extends SQLiteOpenHelper {
 
     private static final String LOGTAG = "GeckoBrowserDBHelper";
-    public static final int DATABASE_VERSION = 20;
+    public static final int DATABASE_VERSION = 21;
     public static final String DATABASE_NAME = "browser.db";
 
     final protected Context mContext;
 
     static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
     static final String TABLE_HISTORY = History.TABLE_NAME;
     static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
     static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
@@ -740,16 +740,20 @@ final class BrowserDatabaseHelper extend
                 " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS +
                     " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID));
     }
 
     @Override
     public void onCreate(SQLiteDatabase db) {
         debug("Creating browser.db: " + db.getPath());
 
+        for (Table table : BrowserProvider.sTables) {
+            table.onCreate(db);
+        }
+
         createBookmarksTableOn13(db);
         createHistoryTableOn13(db);
         createFaviconsTable(db);
         createThumbnailsTable(db);
 
         createBookmarksWithFaviconsView(db);
         createHistoryWithFaviconsView(db);
         createCombinedViewOn19(db);
@@ -1508,16 +1512,20 @@ final class BrowserDatabaseHelper extend
                     break;
 
                 case 20:
                     upgradeDatabaseFrom19to20(db);
                     break;
             }
         }
 
+        for (Table table : BrowserProvider.sTables) {
+            table.onUpgrade(db, oldVersion, newVersion);
+        }
+
         // If an upgrade after 12->13 fails, the entire upgrade is rolled
         // back, but we can't undo the deletion of favicon_urls.db if we
         // delete this in step 13; therefore, we wait until all steps are
         // complete before removing it.
         if (oldVersion < 13 && newVersion >= 13
                             && mContext.getDatabasePath(Obsolete.FAVICON_DB).exists()
                             && !mContext.deleteDatabase(Obsolete.FAVICON_DB)) {
             throw new SQLException("Could not delete " + Obsolete.FAVICON_DB);
--- a/mobile/android/base/db/BrowserProvider.java
+++ b/mobile/android/base/db/BrowserProvider.java
@@ -110,18 +110,22 @@ public class BrowserProvider extends Sha
 
     static final Map<String, String> BOOKMARKS_PROJECTION_MAP;
     static final Map<String, String> HISTORY_PROJECTION_MAP;
     static final Map<String, String> COMBINED_PROJECTION_MAP;
     static final Map<String, String> SCHEMA_PROJECTION_MAP;
     static final Map<String, String> SEARCH_SUGGEST_PROJECTION_MAP;
     static final Map<String, String> FAVICONS_PROJECTION_MAP;
     static final Map<String, String> THUMBNAILS_PROJECTION_MAP;
+    static final Table[] sTables;
 
     static {
+        sTables = new Table[] {
+            new URLMetadataTable()
+        };
         // We will reuse this.
         HashMap<String, String> map;
 
         // Bookmarks
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks", BOOKMARKS);
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/#", BOOKMARKS_ID);
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/parents", BOOKMARKS_PARENT);
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/positions", BOOKMARKS_POSITIONS);
@@ -223,16 +227,22 @@ public class BrowserProvider extends Sha
         map = new HashMap<String, String>();
         map.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
                 Combined.TITLE + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_1);
         map.put(SearchManager.SUGGEST_COLUMN_TEXT_2_URL,
                 Combined.URL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_2_URL);
         map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA,
                 Combined.URL + " AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA);
         SEARCH_SUGGEST_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+        for (Table table : sTables) {
+            for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+                URI_MATCHER.addURI(BrowserContract.AUTHORITY, type.name, type.id);
+            }
+        }
     }
 
     private static boolean hasFaviconsInProjection(String[] projection) {
         if (projection == null) return true;
         for (int i = 0; i < projection.length; ++i) {
             if (projection[i].equals(FaviconColumns.FAVICON) ||
                 projection[i].equals(FaviconColumns.FAVICON_URL))
                 return true;
@@ -346,21 +356,26 @@ public class BrowserProvider extends Sha
                 trace("URI is HISTORY_ID: " + uri);
                 return History.CONTENT_ITEM_TYPE;
             case SEARCH_SUGGEST:
                 trace("URI is SEARCH_SUGGEST: " + uri);
                 return SearchManager.SUGGEST_MIME_TYPE;
             case FLAGS:
                 trace("URI is FLAGS.");
                 return Bookmarks.CONTENT_ITEM_TYPE;
-        }
+            default:
+                String type = getContentItemType(match);
+                if (type != null) {
+                    trace("URI is " + type);
+                    return type;
+                }
 
-        debug("URI has unrecognized type: " + uri);
-
-        return null;
+                debug("URI has unrecognized type: " + uri);
+                return null;
+        }
     }
 
     @SuppressWarnings("fallthrough")
     @Override
     public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
         trace("Calling delete in transaction on URI: " + uri);
         final SQLiteDatabase db = getWritableDatabase(uri);
 
@@ -435,18 +450,25 @@ public class BrowserProvider extends Sha
                 // fall through
             case THUMBNAILS: {
                 trace("Deleting thumbnails: " + uri);
                 beginWrite(db);
                 deleted = deleteThumbnails(uri, selection, selectionArgs);
                 break;
             }
 
-            default:
-                throw new UnsupportedOperationException("Unknown delete URI " + uri);
+            default: {
+                Table table = findTableFor(match);
+                if (table == null) {
+                    throw new UnsupportedOperationException("Unknown delete URI " + uri);
+                }
+                trace("Deleting TABLE: " + uri);
+                beginWrite(db);
+                deleted = table.delete(db, uri, match, selection, selectionArgs);
+            }
         }
 
         debug("Deleted " + deleted + " rows for URI: " + uri);
 
         return deleted;
     }
 
     @Override
@@ -476,18 +498,27 @@ public class BrowserProvider extends Sha
             }
 
             case THUMBNAILS: {
                 trace("Insert on THUMBNAILS: " + uri);
                 id = insertThumbnail(uri, values);
                 break;
             }
 
-            default:
-                throw new UnsupportedOperationException("Unknown insert URI " + uri);
+            default: {
+                Table table = findTableFor(match);
+                if (table == null) {
+                    throw new UnsupportedOperationException("Unknown insert URI " + uri);
+                }
+
+                trace("Insert on TABLE: " + uri);
+                final SQLiteDatabase db = getWritableDatabase(uri);
+                beginWrite(db);
+                id = table.insert(db, uri, match, values);
+            }
         }
 
         debug("Inserted ID in database: " + id);
 
         if (id >= 0)
             return ContentUris.withAppendedId(uri, id);
 
         return null;
@@ -595,18 +626,31 @@ public class BrowserProvider extends Sha
                                                       new String[] { url });
                 } else {
                     updated = updateExistingThumbnail(uri, values, Thumbnails.URL + " = ?",
                                                       new String[] { url });
                 }
                 break;
             }
 
-            default:
-                throw new UnsupportedOperationException("Unknown update URI " + uri);
+            default: {
+                Table table = findTableFor(match);
+                if (table == null) {
+                    throw new UnsupportedOperationException("Unknown update URI " + uri);
+                }
+                trace("Update TABLE: " + uri);
+
+                beginWrite(db);
+                updated = table.update(db, uri, match, values, selection, selectionArgs);
+                if (shouldUpdateOrInsert(uri) && updated == 0) {
+                    trace("No update, inserting for URL: " + uri);
+                    table.insert(db, uri, match, values);
+                    updated = 1;
+                }
+            }
         }
 
         debug("Updated " + updated + " rows for URI: " + uri);
         return updated;
     }
 
     @Override
     public Cursor query(Uri uri, String[] projection, String selection,
@@ -788,18 +832,24 @@ public class BrowserProvider extends Sha
                     sortOrder = DEFAULT_HISTORY_SORT_ORDER;
 
                 qb.setProjectionMap(SEARCH_SUGGEST_PROJECTION_MAP);
                 qb.setTables(VIEW_COMBINED_WITH_FAVICONS);
 
                 break;
             }
 
-            default:
-                throw new UnsupportedOperationException("Unknown query URI " + uri);
+            default: {
+                Table table = findTableFor(match);
+                if (table == null) {
+                    throw new UnsupportedOperationException("Unknown query URI " + uri);
+                }
+                trace("Update TABLE: " + uri);
+                return table.query(db, uri, match, projection, selection, selectionArgs, sortOrder, groupBy, limit);
+            }
         }
 
         trace("Running built query.");
         Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy,
                 null, sortOrder, limit);
         cursor.setNotificationUri(getContext().getContentResolver(),
                 BrowserContract.AUTHORITY_URI);
 
@@ -881,23 +931,17 @@ public class BrowserProvider extends Sha
             if (guids[i] == null) {
                 // We don't want to issue the query if not every GUID is specified.
                 debug("updateBookmarkPositions called with null GUID at index " + i);
                 return 0;
             }
             b.append(" WHEN ? THEN " + i);
         }
 
-        // TODO: use computeSQLInClause
-        b.append(" END WHERE " + Bookmarks.GUID + " IN (");
-        i = 1;
-        while (i++ < processCount) {
-            b.append("?, ");
-        }
-        b.append("?)");
+        b.append(" END WHERE " + DBUtils.computeSQLInClause(processCount, Bookmarks.GUID));
         db.execSQL(b.toString(), args);
 
         // We can't easily get a modified count without calling something like changes().
         return processCount;
     }
 
     /**
      * Construct an update expression that will modify the parents of any records
@@ -977,17 +1021,17 @@ public class BrowserProvider extends Sha
 
         // Compute matching IDs.
         final Cursor cursor = db.query(TABLE_BOOKMARKS, bookmarksProjection,
                                        selection, selectionArgs, null, null, null);
 
         // Now that we're done reading, open a transaction.
         final String inClause;
         try {
-            inClause = computeSQLInClauseFromLongs(cursor, Bookmarks._ID);
+            inClause = DBUtils.computeSQLInClauseFromLongs(cursor, Bookmarks._ID);
         } finally {
             cursor.close();
         }
 
         beginWrite(db);
         return db.update(TABLE_BOOKMARKS, values, inClause, null);
     }
 
@@ -1430,9 +1474,35 @@ public class BrowserProvider extends Sha
         endBatch(db);
 
         if (failures) {
             throw new OperationApplicationException();
         }
 
         return results;
     }
+
+    private static Table findTableFor(int id) {
+        for (Table table : sTables) {
+            for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+                if (type.id == id) {
+                    return table;
+                }
+            }
+        }
+        return null;
+    }
+
+    private static void addTablesToMatcher(Table[] tables, final UriMatcher matcher) {
+    }
+
+    private static String getContentItemType(final int match) {
+        for (Table table : sTables) {
+            for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+                if (type.id == match) {
+                    return "vnd.android.cursor.item/" + type.name;
+                }
+            }
+        }
+
+        return null;
+    }
 }
--- a/mobile/android/base/db/DBUtils.java
+++ b/mobile/android/base/db/DBUtils.java
@@ -2,16 +2,17 @@
  * 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.db;
 
 import org.mozilla.gecko.GeckoAppShell;
 
 import android.content.ContentValues;
+import android.database.Cursor;
 import android.database.sqlite.SQLiteOpenHelper;
 import android.text.TextUtils;
 import android.util.Log;
 
 public class DBUtils {
     private static final String LOGTAG = "GeckoDBUtils";
 
     public static final String qualifyColumn(String table, String column) {
@@ -91,9 +92,50 @@ public class DBUtils {
         if (values.containsKey(columnName)) {
             byte[] data = values.getAsByteArray(columnName);
             if (data == null || data.length == 0) {
                 Log.w(LOGTAG, "Tried to insert an empty or non-byte-array image. Ignoring.");
                 values.putNull(columnName);
             }
         }
     }
+
+    /**
+     * Builds a selection string that searches for a list of arguments in a particular column.
+     * For example URL in (?,?,?). Callers should pass the actual arguments into their query
+     * as selection args.
+     * @para columnName   The column to search in
+     * @para size         The number of arguments to search for
+     */
+    public static String computeSQLInClause(int items, String field) {
+        final StringBuilder builder = new StringBuilder(field);
+        builder.append(" IN (");
+        int i = 0;
+        for (; i < items - 1; ++i) {
+            builder.append("?, ");
+        }
+        if (i < items) {
+            builder.append("?");
+        }
+        builder.append(")");
+        return builder.toString();
+    }
+
+    /**
+     * Turn a single-column cursor of longs into a single SQL "IN" clause.
+     * We can do this without using selection arguments because Long isn't
+     * vulnerable to injection.
+     */
+    public static String computeSQLInClauseFromLongs(final Cursor cursor, String field) {
+        final StringBuilder builder = new StringBuilder(field);
+        builder.append(" IN (");
+        final int commaLimit = cursor.getCount() - 1;
+        int i = 0;
+        while (cursor.moveToNext()) {
+            builder.append(cursor.getLong(0));
+            if (i++ < commaLimit) {
+                builder.append(", ");
+            }
+        }
+        builder.append(")");
+        return builder.toString();
+    }
 }
--- a/mobile/android/base/db/LocalBrowserDB.java
+++ b/mobile/android/base/db/LocalBrowserDB.java
@@ -1192,39 +1192,29 @@ public class LocalBrowserDB implements B
      * Returns null if the provided list of URLs is empty or null.
      */
     @Override
     public Cursor getThumbnailsForUrls(ContentResolver cr, List<String> urls) {
         if (urls == null) {
             return null;
         }
 
-        int urlCount = urls.size();
+        final int urlCount = urls.size();
         if (urlCount == 0) {
             return null;
         }
 
         // Don't match against null thumbnails.
-        StringBuilder selection = new StringBuilder(
-                Thumbnails.DATA + " IS NOT NULL AND " +
-                Thumbnails.URL + " IN ("
-        );
-
-        // Compute a (?, ?, ?) sequence to match the provided URLs.
-        int i = 1;
-        while (i++ < urlCount) {
-            selection.append("?, ");
-        }
-        selection.append("?)");
-
-        String[] selectionArgs = urls.toArray(new String[urlCount]);
+        final String selection = Thumbnails.DATA + " IS NOT NULL AND " +
+                           DBUtils.computeSQLInClause(urlCount, Thumbnails.URL);
+        final String[] selectionArgs = urls.toArray(new String[urlCount]);
 
         return cr.query(mThumbnailsUriWithProfile,
                         new String[] { Thumbnails.URL, Thumbnails.DATA },
-                        selection.toString(),
+                        selection,
                         selectionArgs,
                         null);
     }
 
     @Override
     public void removeThumbnails(ContentResolver cr) {
         cr.delete(mThumbnailsUriWithProfile, null, null);
     }
--- a/mobile/android/base/db/SharedBrowserDatabaseProvider.java
+++ b/mobile/android/base/db/SharedBrowserDatabaseProvider.java
@@ -95,26 +95,20 @@ public abstract class SharedBrowserDatab
         // IDs of matching rows, then delete them in one go.
         final long now = System.currentTimeMillis();
         final String selection = SyncColumns.IS_DELETED + " = 1 AND " +
                 SyncColumns.DATE_MODIFIED + " <= " +
                 (now - MAX_AGE_OF_DELETED_RECORDS);
 
         final String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE);
         final SQLiteDatabase db = getWritableDatabaseForProfile(profile, isTest(fromUri));
-        final String[] ids;
         final String limit = Long.toString(DELETED_RECORDS_PURGE_LIMIT, 10);
         final Cursor cursor = db.query(tableName, new String[] { CommonColumns._ID }, selection, null, null, null, null, limit);
+        final String inClause;
         try {
-            ids = new String[cursor.getCount()];
-            int i = 0;
-            while (cursor.moveToNext()) {
-                ids[i++] = Long.toString(cursor.getLong(0), 10);
-            }
+            inClause = DBUtils.computeSQLInClauseFromLongs(cursor, CommonColumns._ID);
         } finally {
             cursor.close();
         }
 
-        final String inClause = computeSQLInClause(ids.length,
-                CommonColumns._ID);
-        db.delete(tableName, inClause, ids);
+        db.delete(tableName, inClause, null);
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/db/Table.java
@@ -0,0 +1,47 @@
+/* -*- 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.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+// Tables provide a basic wrapper around ContentProvider methods to make it simpler to add new tables into storage.
+// If you create a new Table type, make sure to add it to the sTables list in BrowserProvider to ensure it is queried.
+interface Table {
+    // Provides information to BrowserProvider about the type of URIs this Table can handle.
+    public static class ContentProviderInfo {
+        public final int id; // A number of ID for this table. Used by the UriMatcher in BrowserProvider
+        public final String name; // A name for this table. Will be appended onto uris querying this table
+                                  // This is also used to define the mimetype of data returned from this db, i.e.
+                                  // BrowserProvider will return "vnd.android.cursor.item/" + name
+
+        public ContentProviderInfo(int id, String name) {
+            if (name == null) {
+                throw new IllegalArgumentException("Content provider info must specify a name");
+            }
+            this.id = id;
+            this.name = name;
+        }
+    }
+
+    // Return a list of Info about the ContentProvider URIs this will match
+    ContentProviderInfo[] getContentProviderInfo();
+
+    // Called by BrowserDBHelper whenever the database is created or upgraded.
+    // Order in which tables are created/upgraded isn't guaranteed (yet), so be careful if your Table depends on something in a
+    // separate table.
+    void onCreate(SQLiteDatabase db);
+    void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion);
+
+    // Called by BrowserProvider when this database queried/modified
+    // The dbId here should match the dbId's you returned in your getContentProviderInfo() call
+    Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] projection, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit);
+    int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs);
+    long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values);
+    int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs);
+};
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/db/URLMetadata.java
@@ -0,0 +1,190 @@
+/* -*- 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.db;
+
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.Telemetry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.ContentValues;
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.support.v4.util.LruCache;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+// Holds metadata info about urls. Supports some helper functions for getting back a HashMap of key value data.
+public class URLMetadata {
+    private static final String LOGTAG = "GeckoURLMetadata";
+
+    // This returns a list of columns in the table. It's used to simplify some loops for reading/writing data.
+    @SuppressWarnings("serial")
+    private static final Set<String> getModel() {
+        return new HashSet<String>() {{
+            add(URLMetadataTable.URL_COLUMN);
+            add(URLMetadataTable.TILE_IMAGE_URL_COLUMN);
+            add(URLMetadataTable.TILE_COLOR_COLUMN);
+        }};
+    }
+
+    // Store a cache of recent results. This number is chosen to match the max number of tiles on about:home
+    private static final int CACHE_SIZE = 9;
+    // Note: Members of this cache are unmodifiable.
+    private static final LruCache<String, Map<String, Object>> cache = new LruCache<String, Map<String, Object>>(CACHE_SIZE);
+
+    /**
+     * Converts a JSON object into a unmodifiable Map of known metadata properties.
+     * Will throw away any properties that aren't stored in the database.
+     */
+    public static Map<String, Object> fromJSON(JSONObject obj) {
+        Map<String, Object> data = new HashMap<String, Object>();
+
+        Set<String> model = getModel();
+        for (String key : model) {
+            if (obj.has(key)) {
+                data.put(key, obj.optString(key));
+            }
+        }
+
+        return Collections.unmodifiableMap(data);
+    }
+
+    /**
+     * Converts a Cursor into a unmodifiable Map of known metadata properties.
+     * Will throw away any properties that aren't stored in the database.
+     * Will also not iterate through multiple rows in the cursor.
+     */
+    private static Map<String, Object> fromCursor(Cursor c) {
+        Map<String, Object> data = new HashMap<String, Object>();
+
+        Set<String> model = getModel();
+        String[] columns = c.getColumnNames();
+        for (String column : columns) {
+            if (model.contains(column)) {
+                try {
+                    data.put(column, c.getString(c.getColumnIndexOrThrow(column)));
+                } catch (Exception ex) {
+                    Log.i(LOGTAG, "Error getting data for " + column, ex);
+                }
+            }
+        }
+
+        return Collections.unmodifiableMap(data);
+    }
+
+    /**
+     * Returns an unmodifiable Map of url->Metadata (i.e. A second HashMap) for a list of urls.
+     * Must not be called from UI or Gecko threads.
+     */
+    public static Map<String, Map<String, Object>> getForUrls(final ContentResolver cr,
+                                                              final List<String> urls,
+                                                              final List<String> columns) {
+        ThreadUtils.assertNotOnUiThread();
+        ThreadUtils.assertNotOnGeckoThread();
+
+        final Map<String, Map<String, Object>> data = new HashMap<String, Map<String, Object>>();
+
+        // Nothing to query for
+        if (urls.isEmpty() || columns.isEmpty()) {
+            Log.e(LOGTAG, "Queried metadata for nothing");
+            return data;
+        }
+
+        // Search the cache for any of these urls
+        List<String> urlsToQuery = new ArrayList<String>();
+        for (String url : urls) {
+            final Map<String, Object> hit = cache.get(url);
+            if (hit != null) {
+                // Cache hit!
+                data.put(url, hit);
+            } else {
+                urlsToQuery.add(url);
+            }
+        }
+
+        Telemetry.HistogramAdd("FENNEC_TILES_CACHE_HIT", data.size());
+
+        // If everything was in the cache, we're done!
+        if (urlsToQuery.size() == 0) {
+            return Collections.unmodifiableMap(data);
+        }
+
+        final String selection = DBUtils.computeSQLInClause(urlsToQuery.size(), URLMetadataTable.URL_COLUMN);
+        // We need the url to build our final HashMap, so we force it to be included in the query.
+        if (!columns.contains(URLMetadataTable.URL_COLUMN)) {
+            columns.add(URLMetadataTable.URL_COLUMN);
+        }
+
+        final Cursor cursor = cr.query(URLMetadataTable.CONTENT_URI,
+                                       columns.toArray(new String[columns.size()]), // columns,
+                                       selection, // selection
+                                       urlsToQuery.toArray(new String[urlsToQuery.size()]), // selectionargs
+                                       null);
+        try {
+            if (!cursor.moveToFirst()) {
+                return Collections.unmodifiableMap(data);
+            }
+
+            do {
+                final Map<String, Object> metadata = fromCursor(cursor);
+                final String url = cursor.getString(cursor.getColumnIndexOrThrow(URLMetadataTable.URL_COLUMN));
+
+                data.put(url, metadata);
+                cache.put(url, metadata);
+            } while(cursor.moveToNext());
+
+        } finally {
+            cursor.close();
+        }
+
+        return Collections.unmodifiableMap(data);
+    }
+
+    /**
+     * Saves a HashMap of metadata into the database. Will iterate through columns
+     * in the Database and only save rows with matching keys in the HashMap.
+     * Must not be called from UI or Gecko threads.
+     */
+    public static void save(final ContentResolver cr, final String url, final Map<String, Object> data) {
+        ThreadUtils.assertNotOnUiThread();
+        ThreadUtils.assertNotOnGeckoThread();
+
+        try {
+            ContentValues values = new ContentValues();
+
+            Set<String> model = getModel();
+            for (String key : model) {
+                if (data.containsKey(key)) {
+                    values.put(key, (String) data.get(key));
+                }
+            }
+
+            if (values.size() == 0) {
+                return;
+            }
+
+            Uri uri = URLMetadataTable.CONTENT_URI.buildUpon()
+                                 .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
+                                 .build();
+            cr.update(uri, values, URLMetadataTable.URL_COLUMN + "=?", new String[] {
+                (String) data.get(URLMetadataTable.URL_COLUMN)
+            });
+        } catch (Exception ex) {
+            Log.e(LOGTAG, "error saving", ex);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/db/URLMetadataTable.java
@@ -0,0 +1,72 @@
+/* -*- 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.db;
+
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.Telemetry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.List;
+import java.util.HashMap;
+import java.util.HashSet;
+
+// Holds metadata info about urls. Supports some helper functions for getting back a HashMap of key value data.
+public class URLMetadataTable extends BaseTable {
+    private static final String LOGTAG = "GeckoURLMetadataTable";
+
+    private static final String TABLE = "metadata"; // Name of the table in the db
+    private static final int TABLE_ID_NUMBER = 1200;
+
+    // Uri for querying this table
+    public static final Uri CONTENT_URI = Uri.withAppendedPath(BrowserContract.AUTHORITY_URI, "metadata");
+
+    // Columns in the table
+    public static final String ID_COLUMN = "id";
+    public static final String URL_COLUMN = "url";
+    public static final String TILE_IMAGE_URL_COLUMN = "tileImage";
+    public static final String TILE_COLOR_COLUMN = "tileColor";
+
+    URLMetadataTable() { }
+
+    @Override
+    protected String getTable() {
+        return TABLE;
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        String create = "CREATE TABLE " + TABLE + " (" +
+            ID_COLUMN + " INTEGER PRIMARY KEY, " +
+            URL_COLUMN + " TEXT NON NULL UNIQUE, " +
+            TILE_IMAGE_URL_COLUMN + " STRING, " +
+            TILE_COLOR_COLUMN + " STRING);";
+        db.execSQL(create);
+
+        db.execSQL("CREATE INDEX metadata_url_idx ON " + TABLE + " (" + URL_COLUMN + ")");
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        // This table was added in v19 of the db. Force its creation if we're coming from an earlier version
+        if (newVersion >= 21 && oldVersion < 21) {
+            onCreate(db);
+        }
+    }
+
+    @Override
+    public Table.ContentProviderInfo[] getContentProviderInfo() {
+        return new Table.ContentProviderInfo[] {
+            new Table.ContentProviderInfo(TABLE_ID_NUMBER, TABLE)
+        };
+    }
+}
--- a/mobile/android/base/gfx/BitmapUtils.java
+++ b/mobile/android/base/gfx/BitmapUtils.java
@@ -117,17 +117,17 @@ public final class BitmapUtils {
         if (data.startsWith("-moz-icon://")) {
             final Uri imageUri = Uri.parse(data);
             final String ssp = imageUri.getSchemeSpecificPart();
             final String resource = ssp.substring(ssp.lastIndexOf('/') + 1);
 
             try {
                 final Drawable d = context.getPackageManager().getApplicationIcon(resource);
                 runOnBitmapFoundOnUiThread(loader, d);
-            } catch(Exception ex) { }
+            } catch (Exception ex) { }
 
             return;
         }
 
         if (data.startsWith("drawable://")) {
             final Uri imageUri = Uri.parse(data);
             final int id = getResource(imageUri, R.drawable.ic_status_logo);
             final Drawable d = context.getResources().getDrawable(id);
--- a/mobile/android/base/home/TopSitesGridItemView.java
+++ b/mobile/android/base/home/TopSitesGridItemView.java
@@ -1,25 +1,34 @@
 /* -*- 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 com.squareup.picasso.Picasso;
+import com.squareup.picasso.Callback;
 
 import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.db.URLMetadata;
 import org.mozilla.gecko.favicons.Favicons;
+import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UiAsyncTask;
 
 import android.content.Context;
+import android.content.ContentResolver;
 import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.support.v4.content.AsyncTaskLoader;
 import android.text.TextUtils;
 import android.util.AttributeSet;
+import android.util.Log;
 import android.view.LayoutInflater;
 import android.widget.ImageView;
 import android.widget.ImageView.ScaleType;
 import android.widget.RelativeLayout;
 import android.widget.TextView;
 
 /**
  * A view that displays the thumbnail and the title/url for a top/pinned site.
@@ -150,30 +159,34 @@ public class TopSitesGridItemView extend
 
     /**
      * Updates the title, URL, and pinned state of this view.
      *
      * Also resets our loadId to NOT_LOADING.
      *
      * Returns true if any fields changed.
      */
-    public boolean updateState(final String title, final String url, final int type, final Bitmap thumbnail) {
+    public boolean updateState(final String title, final String url, final int type, final TopSitesPanel.ThumbnailInfo thumbnail) {
         boolean changed = false;
         if (mUrl == null || !mUrl.equals(url)) {
             mUrl = url;
             changed = true;
         }
 
         if (mTitle == null || !mTitle.equals(title)) {
             mTitle = title;
             changed = true;
         }
 
         if (thumbnail != null) {
-            displayThumbnail(thumbnail);
+            if (thumbnail.imageUrl != null) {
+                displayThumbnail(thumbnail.imageUrl, thumbnail.bgColor);
+            } else if (thumbnail.bitmap != null) {
+                displayThumbnail(thumbnail.bitmap);
+            }
         } else if (changed) {
             // Because we'll have a new favicon or thumbnail arriving shortly, and
             // we need to not reject it because we already had a thumbnail.
             mThumbnailSet = false;
         }
 
         if (changed) {
             updateTitleView();
@@ -227,17 +240,17 @@ public class TopSitesGridItemView extend
     }
 
     /**
      * Display the thumbnail from a URL.
      *
      * @param imageUrl URL of the image to show.
      * @param bgColor background color to use in the view.
      */
-    public void displayThumbnail(String imageUrl, int bgColor) {
+    public void displayThumbnail(final String imageUrl, final int bgColor) {
         mThumbnailView.setScaleType(SCALE_TYPE_RESOURCE);
         mThumbnailView.setBackgroundColor(bgColor);
         mThumbnailSet = true;
 
         Picasso.with(getContext())
                .load(imageUrl)
                .noFade()
                .error(R.drawable.favicon)
--- a/mobile/android/base/home/TopSitesPanel.java
+++ b/mobile/android/base/home/TopSitesPanel.java
@@ -3,41 +3,48 @@
  * 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 java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 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.db.URLMetadata;
+import org.mozilla.gecko.db.URLMetadataTable;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.gfx.BitmapUtils;
 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.util.StringUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN;
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN;
+
 import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Configuration;
 import android.database.Cursor;
 import android.graphics.Bitmap;
+import android.graphics.Color;
 import android.net.Uri;
 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;
@@ -416,17 +423,17 @@ public class TopSitesPanel extends HomeF
             });
         }
     }
 
     private void updateUiFromCursor(Cursor c) {
         mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries);
     }
 
-    private void updateUiWithThumbnails(Map<String, Bitmap> thumbnails) {
+    private void updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails) {
         if (mGridAdapter != null) {
             mGridAdapter.updateThumbnails(thumbnails);
         }
 
         // Once thumbnails have finished loading, the UI is ready. Reset
         // Gecko to normal priority.
         ThreadUtils.resetGeckoPriority();
     }
@@ -481,17 +488,17 @@ public class TopSitesPanel extends HomeF
         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 {
         // Cache to store the thumbnails.
         // Ensure that this is only accessed from the UI thread.
-        private Map<String, Bitmap> mThumbnails;
+        private Map<String, ThumbnailInfo> mThumbnailInfos;
 
         public TopSitesGridAdapter(Context context, Cursor cursor) {
             super(context, cursor, 0);
         }
 
         @Override
         public int getCount() {
             return Math.min(mMaxGridEntries, super.getCount());
@@ -504,18 +511,18 @@ public class TopSitesPanel extends HomeF
             return;
         }
 
         /**
          * Update the thumbnails returned by the db.
          *
          * @param thumbnails A map of urls and their thumbnail bitmaps.
          */
-        public void updateThumbnails(Map<String, Bitmap> thumbnails) {
-            mThumbnails = thumbnails;
+        public void updateThumbnails(Map<String, ThumbnailInfo> 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.
@@ -535,17 +542,17 @@ public class TopSitesPanel extends HomeF
 
             // If there is no url, then show "add bookmark".
             if (type == TopSites.TYPE_BLANK) {
                 view.blankOut();
                 return;
             }
 
             // Show the thumbnail, if any.
-            Bitmap thumbnail = (mThumbnails != null ? mThumbnails.get(url) : null);
+            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...
@@ -563,17 +570,17 @@ public class TopSitesPanel extends HomeF
             if (!TextUtils.isEmpty(imageUrl)) {
                 final int bgColor = BrowserDB.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 (mThumbnails == null || thumbnail != null) {
+            if (mThumbnailInfos == null || thumbnail != null) {
                 return;
             }
 
             // If we have no thumbnail, attempt to show a Favicon instead.
             LoadIDAwareFaviconLoadedListener listener = new LoadIDAwareFaviconLoadedListener(view);
             final int loadId = Favicons.getSizedFaviconForPageFromLocal(url, listener);
             if (loadId == Favicons.LOADED) {
                 // Great!
@@ -660,17 +667,17 @@ public class TopSitesPanel extends HomeF
                     continue;
                 }
 
                 urls.add(url);
             } while (i++ < mMaxGridEntries && c.moveToNext());
 
             if (urls.isEmpty()) {
                 // Short-circuit empty results to the UI.
-                updateUiWithThumbnails(new HashMap<String, Bitmap>());
+                updateUiWithThumbnails(new HashMap<String, ThumbnailInfo>());
                 return;
             }
 
             Bundle bundle = new Bundle();
             bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls);
             getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks);
         }
 
@@ -681,43 +688,107 @@ public class TopSitesPanel extends HomeF
             }
 
             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<String, Object> data) {
+            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.
      */
-    private static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, Bitmap>> {
-        private Map<String, Bitmap> mThumbnails;
+    @SuppressWarnings("serial")
+    static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, ThumbnailInfo>> {
+        private Map<String, ThumbnailInfo> mThumbnailInfos;
         private ArrayList<String> mUrls;
 
+        private static final ArrayList<String> COLUMNS = new ArrayList<String>() {{
+            add(TILE_IMAGE_URL_COLUMN);
+            add(TILE_COLOR_COLUMN);
+        }};
+
         public ThumbnailsLoader(Context context, ArrayList<String> urls) {
             super(context);
             mUrls = urls;
         }
 
         @Override
-        public Map<String, Bitmap> loadInBackground() {
+        public Map<String, ThumbnailInfo> loadInBackground() {
+            final Map<String, ThumbnailInfo> thumbnails = new HashMap<String, ThumbnailInfo>();
             if (mUrls == null || mUrls.size() == 0) {
-                return null;
+                return thumbnails;
             }
 
-            // Query the DB for thumbnails.
+            // Query the DB for tile images.
             final ContentResolver cr = getContext().getContentResolver();
-            final Cursor cursor = BrowserDB.getThumbnailsForUrls(cr, mUrls);
+            final Map<String, Map<String, Object>> metadata = URLMetadata.getForUrls(cr, mUrls, COLUMNS);
+
+            // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead.
+            final List<String> thumbnailUrls;
+            if (metadata != null) {
+                thumbnailUrls = new ArrayList<String>();
 
-            if (cursor == null) {
-                return null;
+                for (String url : metadata.keySet()) {
+                    ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(url));
+                    if (info == null) {
+                        // If we didn't find metadata, we'll look for a thumbnail for this url.
+                        thumbnailUrls.add(url);
+                        continue;
+                    }
+
+                    thumbnails.put(url, info);
+                }
+            } else {
+                thumbnailUrls = new ArrayList<String>(mUrls);
             }
 
-            final Map<String, Bitmap> thumbnails = new HashMap<String, Bitmap>();
+            if (thumbnailUrls.size() == 0) {
+                return thumbnails;
+            }
+
+            // Query the DB for tile thumbnails.
+            final Cursor cursor = BrowserDB.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);
 
@@ -732,85 +803,85 @@ public class TopSitesPanel extends HomeF
                     // Our thumbnails are never null, so if we get 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, bitmap);
+                    thumbnails.put(url, new ThumbnailInfo(bitmap));
                 }
             } finally {
                 cursor.close();
             }
 
             return thumbnails;
         }
 
         @Override
-        public void deliverResult(Map<String, Bitmap> thumbnails) {
+        public void deliverResult(Map<String, ThumbnailInfo> thumbnails) {
             if (isReset()) {
-                mThumbnails = null;
+                mThumbnailInfos = null;
                 return;
             }
 
-            mThumbnails = thumbnails;
+            mThumbnailInfos = thumbnails;
 
             if (isStarted()) {
                 super.deliverResult(thumbnails);
             }
         }
 
         @Override
         protected void onStartLoading() {
-            if (mThumbnails != null) {
-                deliverResult(mThumbnails);
+            if (mThumbnailInfos != null) {
+                deliverResult(mThumbnailInfos);
             }
 
-            if (takeContentChanged() || mThumbnails == null) {
+            if (takeContentChanged() || mThumbnailInfos == null) {
                 forceLoad();
             }
         }
 
         @Override
         protected void onStopLoading() {
             cancelLoad();
         }
 
         @Override
-        public void onCanceled(Map<String, Bitmap> thumbnails) {
-            mThumbnails = null;
+        public void onCanceled(Map<String, ThumbnailInfo> thumbnails) {
+            mThumbnailInfos = null;
         }
 
         @Override
         protected void onReset() {
             super.onReset();
 
             // Ensure the loader is stopped.
             onStopLoading();
 
-            mThumbnails = null;
+            mThumbnailInfos = null;
         }
     }
 
     /**
      * Loader callbacks for the thumbnails on TopSitesGridView.
      */
-    private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, Bitmap>> {
+    private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, ThumbnailInfo>> {
         @Override
-        public Loader<Map<String, Bitmap>> onCreateLoader(int id, Bundle args) {
+        public Loader<Map<String, ThumbnailInfo>> onCreateLoader(int id, Bundle args) {
             return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY));
         }
 
         @Override
-        public void onLoadFinished(Loader<Map<String, Bitmap>> loader, Map<String, Bitmap> thumbnails) {
+        public void onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails) {
             updateUiWithThumbnails(thumbnails);
         }
 
         @Override
-        public void onLoaderReset(Loader<Map<String, Bitmap>> loader) {
+        public void onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader) {
             if (mGridAdapter != null) {
                 mGridAdapter.updateThumbnails(null);
             }
         }
     }
 }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -133,34 +133,38 @@ gbjar.sources += [
     'BrowserApp.java',
     'BrowserLocaleManager.java',
     'ContactService.java',
     'ContextGetter.java',
     'CustomEditText.java',
     'DataReportingNotification.java',
     'db/AbstractPerProfileDatabaseProvider.java',
     'db/AbstractTransactionalProvider.java',
+    'db/BaseTable.java',
     'db/BrowserContract.java',
     'db/BrowserDatabaseHelper.java',
     'db/BrowserDB.java',
     'db/BrowserProvider.java',
     'db/DBUtils.java',
     'db/FormHistoryProvider.java',
     'db/HomeProvider.java',
     'db/LocalBrowserDB.java',
     'db/PasswordsProvider.java',
     'db/PerProfileDatabaseProvider.java',
     'db/PerProfileDatabases.java',
     'db/ReadingListProvider.java',
     'db/SearchHistoryProvider.java',
     'db/SharedBrowserDatabaseProvider.java',
     'db/SQLiteBridgeContentProvider.java',
     'db/SuggestedSites.java',
+    'db/Table.java',
     'db/TabsProvider.java',
     'db/TopSitesCursorWrapper.java',
+    'db/URLMetadata.java',
+    'db/URLMetadataTable.java',
     'distribution/Distribution.java',
     'distribution/ReferrerDescriptor.java',
     'distribution/ReferrerReceiver.java',
     'DoorHangerPopup.java',
     'DynamicToolbar.java',
     'EditBookmarkDialog.java',
     'EventDispatcher.java',
     'favicons/cache/FaviconCache.java',
--- a/mobile/android/base/util/ThreadUtils.java
+++ b/mobile/android/base/util/ThreadUtils.java
@@ -124,16 +124,20 @@ public final class ThreadUtils {
         assertNotOnThread(getUiThread(), AssertBehavior.THROW);
     }
 
     @RobocopTarget
     public static void assertOnGeckoThread() {
         assertOnThread(sGeckoThread, AssertBehavior.THROW);
     }
 
+    public static void assertNotOnGeckoThread() {
+        assertNotOnThread(sGeckoThread, AssertBehavior.THROW);
+    }
+
     public static void assertOnBackgroundThread() {
         assertOnThread(getBackgroundThread(), AssertBehavior.THROW);
     }
 
     public static void assertOnThread(final Thread expectedThread) {
         assertOnThread(expectedThread, AssertBehavior.THROW);
     }
 
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -3161,16 +3161,17 @@ Tab.prototype = {
                 Ci.nsIWebProgress.NOTIFY_SECURITY;
     this.browser.addProgressListener(this, flags);
     this.browser.sessionHistory.addSHistoryListener(this);
 
     this.browser.addEventListener("DOMContentLoaded", this, true);
     this.browser.addEventListener("DOMFormHasPassword", this, true);
     this.browser.addEventListener("DOMLinkAdded", this, true);
     this.browser.addEventListener("DOMLinkChanged", this, true);
+    this.browser.addEventListener("DOMMetaAdded", this, false);
     this.browser.addEventListener("DOMTitleChanged", this, true);
     this.browser.addEventListener("DOMWindowClose", this, true);
     this.browser.addEventListener("DOMWillOpenModalDialog", this, true);
     this.browser.addEventListener("DOMAutoComplete", this, true);
     this.browser.addEventListener("blur", this, true);
     this.browser.addEventListener("scroll", this, true);
     this.browser.addEventListener("MozScrolledAreaChanged", this, true);
     this.browser.addEventListener("pageshow", this, true);
@@ -3334,16 +3335,17 @@ Tab.prototype = {
 
     this.browser.removeProgressListener(this);
     this.browser.sessionHistory.removeSHistoryListener(this);
 
     this.browser.removeEventListener("DOMContentLoaded", this, true);
     this.browser.removeEventListener("DOMFormHasPassword", this, true);
     this.browser.removeEventListener("DOMLinkAdded", this, true);
     this.browser.removeEventListener("DOMLinkChanged", this, true);
+    this.browser.removeEventListener("DOMMetaAdded", this, false);
     this.browser.removeEventListener("DOMTitleChanged", this, true);
     this.browser.removeEventListener("DOMWindowClose", this, true);
     this.browser.removeEventListener("DOMWillOpenModalDialog", this, true);
     this.browser.removeEventListener("DOMAutoComplete", this, true);
     this.browser.removeEventListener("blur", this, true);
     this.browser.removeEventListener("scroll", this, true);
     this.browser.removeEventListener("MozScrolledAreaChanged", this, true);
     this.browser.removeEventListener("pageshow", this, true);
@@ -3677,16 +3679,35 @@ Tab.prototype = {
       // If the page changed size twice since we last measured the viewport and
       // the latest size change reveals we don't need to remeasure, cancel any
       // pending remeasure.
       clearTimeout(this.viewportMeasureCallback);
       this.viewportMeasureCallback = null;
     }
   },
 
+  // These constants are used to prioritize high quality metadata over low quality data, so that
+  // we can collect data as we find meta tags, and replace low quality metadata with higher quality
+  // matches. For instance a msApplicationTile icon is a better tile image than an og:image tag.
+  METADATA_GOOD_MATCH: 10,
+  METADATA_NORMAL_MATCH: 1,
+
+  addMetadata: function(type, value, quality = 1) {
+    if (!this.metatags) {
+      this.metatags = {
+        url: this.browser.currentURI.spec
+      };
+    }
+
+    if (!this.metatags[type] || this.metatags[type + "_quality"] < quality) {
+      this.metatags[type] = value;
+      this.metatags[type + "_quality"] = quality;
+    }
+  },
+
   handleEvent: function(aEvent) {
     switch (aEvent.type) {
       case "DOMContentLoaded": {
         let target = aEvent.originalTarget;
 
         // ignore on frames and other documents
         if (target != this.browser.contentDocument)
           return;
@@ -3711,19 +3732,22 @@ Tab.prototype = {
           errorType = "blocked"
         else if (docURI.startsWith("about:neterror"))
           errorType = "neterror";
 
         sendMessageToJava({
           type: "DOMContentLoaded",
           tabID: this.id,
           bgColor: backgroundColor,
-          errorType: errorType
+          errorType: errorType,
+          metadata: this.metatags
         });
 
+        this.metatags = null;
+
         // Attach a listener to watch for "click" events bubbling up from error
         // pages and other similar page. This lets us fix bugs like 401575 which
         // require error page UI to do privileged things, without letting error
         // pages have any privilege themselves.
         if (docURI.startsWith("about:certerror") || docURI.startsWith("about:blocked")) {
           this.browser.addEventListener("click", ErrorPageEventHandler, true);
           let listener = function() {
             this.browser.removeEventListener("click", ErrorPageEventHandler, true);
@@ -3747,16 +3771,31 @@ Tab.prototype = {
         break;
       }
 
       case "DOMFormHasPassword": {
         LoginManagerContent.onFormPassword(aEvent);
         break;
       }
 
+      case "DOMMetaAdded":
+        let target = aEvent.originalTarget;
+        let browser = BrowserApp.getBrowserForDocument(target.ownerDocument);
+
+        switch (target.name) {
+          case "msapplication-TileImage":
+            this.addMetadata("tileImage", browser.currentURI.resolve(target.content), this.METADATA_GOOD_MATCH);
+            break;
+          case "msapplication-TileColor":
+            this.addMetadata("tileColor", target.content, this.METADATA_GOOD_MATCH);
+            break;
+        }
+
+        break;
+
       case "DOMLinkAdded":
       case "DOMLinkChanged": {
         let target = aEvent.originalTarget;
         if (!target.href || target.disabled)
           return;
 
         // Ignore on frames and other documents
         if (target.ownerDocument != this.browser.contentDocument)
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -6505,16 +6505,23 @@
     "n_values": 30,
     "description": "Algorithms used with WebCrypto (see table in WebCryptoTask.cpp)"
   },
   "MASTER_PASSWORD_ENABLED": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "If a master-password is enabled for this profile"
   },
+  "FENNEC_TILES_CACHE_HIT": {
+    "expires_in_version": "never",
+    "kind": "linear",
+    "high": "13",
+    "n_buckets": 12,
+    "description": "Cache hits on the tile-info metadata database"
+  },
   "DISPLAY_SCALING_OSX" : {
     "expires_in_version": "never",
     "kind": "linear",
     "high": "500",
     "n_buckets": "100",
     "description": "Scaling percentage for the display where the first window is opened (OS X only)",
     "cpp_guard": "XP_MACOSX"
   },