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 194308 0814bb0f08d097ee4eb0bd7b2e5794357f76c766
parent 194205 e9d78c3d2eb584b74266b6579cbb1e5ee45005fd
child 194309 6ee3c3ba17b589a7dd9faa33bdeffdf3823d158d
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersrnewman, lucasr
bugs1014712
milestone33.0a1
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"
   },