Bug 1301717 - Limit what we extract, store incoming page metadata r=sebastian draft
authorGrisha Kruglov <gkruglov@mozilla.com>
Fri, 04 Nov 2016 13:39:50 -0700
changeset 434091 5839e04f82e174f50a61c107d271b4bda3f748c8
parent 434090 ecc499269264ece6413ef8e05658cf2c9a1e4e5b
child 536019 6c757c95a629919112cc42b301c03b37d590d6a0
push id34718
push userbmo:gkruglov@mozilla.com
push dateFri, 04 Nov 2016 20:41:37 +0000
reviewerssebastian
bugs1301717
milestone52.0a1
Bug 1301717 - Limit what we extract, store incoming page metadata r=sebastian We only extract metadata that we'd want to store. For now this is just image_url. In case we try to store page metadata before corresponding history record has been inserted, we queue up metadata and wait for an event from GlobalHistory letting us know that history record is now available, at which point we try inserting metadata again. MozReview-Commit-ID: 2FpIqc6uHMb
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
mobile/android/base/moz.build
mobile/android/modules/WebsiteMetadata.jsm
mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -3,16 +3,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;
 
 import android.Manifest;
 import android.annotation.TargetApi;
 import android.app.DownloadManager;
+import android.content.ContentProviderClient;
 import android.os.Environment;
 import android.os.Process;
 import android.support.annotation.CheckResult;
 import android.support.annotation.NonNull;
 
 import android.graphics.Rect;
 
 import org.json.JSONArray;
@@ -1923,17 +1924,39 @@ public class BrowserApp extends GeckoApp
                     Log.e(LOGTAG, "Download failed: " + e);
                 }
                 break;
 
             case "Website:Metadata":
                 final NativeJSObject metadata = message.getObject("metadata");
                 final String location = message.getString("location");
 
-                // TODO: Store metadata (Bug 1301717)
+                final boolean hasImage = !TextUtils.isEmpty(metadata.optString("image_url", null));
+                final String metadataJSON = metadata.toString();
+
+                ThreadUtils.postToBackgroundThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        final ContentProviderClient contentProviderClient = getContentResolver()
+                                .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI);
+                        if (contentProviderClient == null) {
+                            Log.w(LOGTAG, "Failed to obtain content provider client for: " + BrowserContract.PageMetadata.CONTENT_URI);
+                            return;
+                        }
+                        try {
+                            GlobalPageMetadata.getInstance().add(
+                                    BrowserDB.from(getProfile()),
+                                    contentProviderClient,
+                                    location, hasImage, metadataJSON);
+                        } finally {
+                            contentProviderClient.release();
+                        }
+                    }
+                });
+
                 break;
 
             default:
                 super.handleMessage(event, message, callback);
                 break;
         }
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -161,16 +161,18 @@ public class GeckoApplication extends Ap
         final Context context = getApplicationContext();
         GeckoAppShell.setApplicationContext(context);
         HardwareUtils.init(context);
         Clipboard.init(context);
         FilePicker.init(context);
         DownloadsIntegration.init();
         HomePanelsManager.getInstance().init(context);
 
+        GlobalPageMetadata.getInstance().init();
+
         // We need to set the notification client before launching Gecko, since Gecko could start
         // sending notifications immediately after startup, which we don't want to lose/crash on.
         GeckoAppShell.setNotificationListener(new NotificationClient(context));
         // This getInstance call will force initialization of the NotificationHelper, but does nothing with the result
         NotificationHelper.getInstance(context).init();
 
         MulticastDNSManager.getInstance(context).init();
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
@@ -0,0 +1,182 @@
+/* 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;
+
+import android.content.ContentProviderClient;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Provides access to metadata information about websites.
+ *
+ * While storing, in case of timing issues preventing us from looking up History GUID by a given uri,
+ * we queue up metadata and wait for GlobalHistory to let us know history record is now available.
+ *
+ * TODO Bug 1313515: selection of metadata for a given uri/history_GUID
+ *
+ * @author grisha
+ */
+/* package-local */ class GlobalPageMetadata implements BundleEventListener {
+    private static final String LOG_TAG = "GeckoGlobalPageMetadata";
+
+    private static final GlobalPageMetadata instance = new GlobalPageMetadata();
+
+    private static final String KEY_HAS_IMAGE = "hasImage";
+    private static final String KEY_METADATA_JSON = "metadataJSON";
+
+    private static final int MAX_METADATA_QUEUE_SIZE = 15;
+
+    private final Map<String, Bundle> queuedMetadata = Collections.synchronizedMap(new LimitedLinkedHashMap<String, Bundle>());
+
+    public static GlobalPageMetadata getInstance() {
+        return instance;
+    }
+
+    private static class LimitedLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
+        private static final long serialVersionUID = 6359725112736360244L;
+
+        @Override
+        protected boolean removeEldestEntry(Entry<K, V> eldest) {
+            if (size() > MAX_METADATA_QUEUE_SIZE) {
+                Log.w(LOG_TAG, "Page metadata queue is full. Dropping oldest metadata.");
+                return true;
+            }
+            return false;
+        }
+    }
+
+    private GlobalPageMetadata() {}
+
+    public void init() {
+        EventDispatcher
+                .getInstance()
+                .registerBackgroundThreadListener(this, GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY);
+    }
+
+    public void add(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) {
+        ThreadUtils.assertOnBackgroundThread();
+
+        // NB: Other than checking that JSON is valid and trimming it,
+        // we do not process metadataJSON in any way, trusting our source.
+        doAddOrQueue(db, contentProviderClient, uri, hasImage, metadataJSON);
+    }
+
+    @VisibleForTesting
+    /*package-local */ void doAddOrQueue(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) {
+        final String preparedMetadataJSON;
+        try {
+            preparedMetadataJSON = prepareJSON(metadataJSON);
+        } catch (JSONException e) {
+            Log.e(LOG_TAG, "Couldn't process metadata JSON", e);
+            return;
+        }
+
+        // Don't bother queuing this if deletions fails to find a corresponding history record.
+        // If we can't delete metadata because it didn't exist yet, that's OK.
+        if (preparedMetadataJSON.equals("{}")) {
+            final int deleted = db.deletePageMetadata(contentProviderClient, uri);
+            // We could delete none if history record for uri isn't present.
+            // We must delete one if history record for uri is present.
+            if (deleted != 0 && deleted != 1) {
+                throw new IllegalStateException("Deleted unexpected number of page metadata records: " + deleted);
+            }
+            return;
+        }
+
+        // If we could insert page metadata, we're done.
+        if (db.insertPageMetadata(contentProviderClient, uri, hasImage, preparedMetadataJSON)) {
+            return;
+        }
+
+        // Otherwise, we need to queue it for future insertion when history record is available.
+        Bundle bundledMetadata = new Bundle();
+        bundledMetadata.putBoolean(KEY_HAS_IMAGE, hasImage);
+        bundledMetadata.putString(KEY_METADATA_JSON, preparedMetadataJSON);
+        queuedMetadata.put(uri, bundledMetadata);
+    }
+
+    @VisibleForTesting
+    /* package-local */ int getMetadataQueueSize() {
+        return queuedMetadata.size();
+    }
+
+    @Override
+    public void handleMessage(String event, Bundle message, EventCallback callback) {
+        ThreadUtils.assertOnBackgroundThread();
+
+        if (!GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY.equals(event)) {
+            return;
+        }
+
+        final String uri = message.getString(GlobalHistory.EVENT_PARAM_URI);
+        if (TextUtils.isEmpty(uri)) {
+            return;
+        }
+
+        final Bundle bundledMetadata;
+        synchronized (queuedMetadata) {
+            if (!queuedMetadata.containsKey(uri)) {
+                return;
+            }
+
+            bundledMetadata = queuedMetadata.get(uri);
+            queuedMetadata.remove(uri);
+        }
+
+        insertMetadataBundleForUri(uri, bundledMetadata);
+    }
+
+    private void insertMetadataBundleForUri(String uri, Bundle bundledMetadata) {
+        final boolean hasImage = bundledMetadata.getBoolean(KEY_HAS_IMAGE);
+        final String metadataJSON = bundledMetadata.getString(KEY_METADATA_JSON);
+
+        // Acquire CPC, must be released in this function.
+        final ContentProviderClient contentProviderClient = GeckoAppShell.getApplicationContext()
+                .getContentResolver()
+                .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI);
+
+        // Pre-conditions...
+        if (contentProviderClient == null) {
+            Log.e(LOG_TAG, "Couldn't acquire content provider client");
+            return;
+        }
+
+        if (TextUtils.isEmpty(metadataJSON)) {
+            Log.e(LOG_TAG, "Metadata bundle contained empty metadata json");
+            return;
+        }
+
+        // Insert!
+        try {
+            add(
+                    BrowserDB.from(GeckoThread.getActiveProfile()),
+                    contentProviderClient,
+                    uri, hasImage, metadataJSON
+            );
+        } finally {
+            contentProviderClient.release();
+        }
+    }
+
+    private String prepareJSON(String json) throws JSONException {
+        return (new JSONObject(json)).toString();
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -10,16 +10,17 @@ import java.util.EnumSet;
 import java.util.List;
 
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
 
+import android.content.ContentProviderClient;
 import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.graphics.drawable.BitmapDrawable;
 import android.support.v4.content.CursorLoader;
 
@@ -107,16 +108,18 @@ public abstract class BrowserDB {
     public abstract boolean addBookmark(ContentResolver cr, String title, String uri);
     public abstract Cursor getBookmarkForUrl(ContentResolver cr, String url);
     public abstract Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl);
     public abstract void removeBookmarksWithURL(ContentResolver cr, String uri);
     public abstract void registerBookmarkObserver(ContentResolver cr, ContentObserver observer);
     public abstract void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword);
     public abstract boolean hasBookmarkWithGuid(ContentResolver cr, String guid);
 
+    public abstract boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON);
+    public abstract int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl);
     /**
      * Can return <code>null</code>.
      */
     public abstract Cursor getBookmarksInFolder(ContentResolver cr, long folderId);
 
     public abstract int getBookmarkCountForFolder(ContentResolver cr, long folderId);
 
     /**
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -32,37 +32,40 @@ import org.mozilla.gecko.db.BrowserContr
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
 import org.mozilla.gecko.db.BrowserContract.Favicons;
 import org.mozilla.gecko.db.BrowserContract.History;
 import org.mozilla.gecko.db.BrowserContract.SyncColumns;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.db.BrowserContract.TopSites;
 import org.mozilla.gecko.db.BrowserContract.Highlights;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.icons.decoders.FaviconDecoder;
 import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.util.GeckoJarReader;
 import org.mozilla.gecko.util.StringUtils;
 
+import android.content.ContentProviderClient;
 import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.database.MatrixCursor;
 import android.database.MergeCursor;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.drawable.BitmapDrawable;
 import android.net.Uri;
+import android.os.RemoteException;
 import android.os.SystemClock;
 import android.support.annotation.CheckResult;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.v4.content.CursorLoader;
 import android.text.TextUtils;
 import android.util.Log;
 import org.mozilla.gecko.util.IOUtils;
@@ -115,16 +118,17 @@ public class LocalBrowserDB extends Brow
     private final Uri mCombinedUriWithProfile;
     private final Uri mUpdateHistoryUriWithProfile;
     private final Uri mFaviconsUriWithProfile;
     private final Uri mThumbnailsUriWithProfile;
     private final Uri mTopSitesUriWithProfile;
     private final Uri mHighlightsUriWithProfile;
     private final Uri mSearchHistoryUri;
     private final Uri mActivityStreamBlockedUriWithProfile;
+    private final Uri mPageMetadataWithProfile;
 
     private LocalSearches searches;
     private LocalTabsAccessor tabsAccessor;
     private LocalURLMetadata urlMetadata;
     private LocalUrlAnnotations urlAnnotations;
 
     private static final String[] DEFAULT_BOOKMARK_COLUMNS =
             new String[] { Bookmarks._ID,
@@ -144,16 +148,18 @@ public class LocalBrowserDB extends Brow
         mHistoryExpireUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_OLD_URI);
         mCombinedUriWithProfile = DBUtils.appendProfile(profile, Combined.CONTENT_URI);
         mFaviconsUriWithProfile = DBUtils.appendProfile(profile, Favicons.CONTENT_URI);
         mTopSitesUriWithProfile = DBUtils.appendProfile(profile, TopSites.CONTENT_URI);
         mHighlightsUriWithProfile = DBUtils.appendProfile(profile, Highlights.CONTENT_URI);
         mThumbnailsUriWithProfile = DBUtils.appendProfile(profile, Thumbnails.CONTENT_URI);
         mActivityStreamBlockedUriWithProfile = DBUtils.appendProfile(profile, ActivityStreamBlocklist.CONTENT_URI);
 
+        mPageMetadataWithProfile = DBUtils.appendProfile(profile, PageMetadata.CONTENT_URI);
+
         mSearchHistoryUri = BrowserContract.SearchHistory.CONTENT_URI;
 
         mUpdateHistoryUriWithProfile =
                 mHistoryUriWithProfile.buildUpon()
                                       .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
                                       .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
                                       .build();
 
@@ -504,16 +510,91 @@ public class LocalBrowserDB extends Brow
             // specify an icon; otherwise, the placeholder globe favicon will be used.
             Log.d(LOGTAG, "No raw favicon resource found for " + name);
         }
 
         Log.e(LOGTAG, "Failed to find favicon resource ID for " + name);
         return FAVICON_ID_NOT_FOUND;
     }
 
+    @Override
+    public boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON) {
+        final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl);
+
+        if (historyGUID == null) {
+            return false;
+        }
+
+        // We have the GUID, insert the metadata.
+        final ContentValues cv = new ContentValues();
+        cv.put(PageMetadata.HISTORY_GUID, historyGUID);
+        cv.put(PageMetadata.HAS_IMAGE, hasImage);
+        cv.put(PageMetadata.JSON, metadataJSON);
+
+        try {
+            contentProviderClient.insert(mPageMetadataWithProfile, cv);
+        } catch (RemoteException e) {
+            throw new IllegalStateException("Unexpected RemoteException", e);
+        }
+
+        return true;
+    }
+
+    @Override
+    public int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl) {
+        final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl);
+
+        if (historyGUID == null) {
+            return 0;
+        }
+
+        try {
+            return contentProviderClient.delete(mPageMetadataWithProfile, PageMetadata.HISTORY_GUID + " = ?", new String[]{historyGUID});
+        } catch (RemoteException e) {
+            throw new IllegalStateException("Unexpected RemoteException", e);
+        }
+    }
+
+    @Nullable
+    private String lookupHistoryGUIDByPageUri(ContentProviderClient contentProviderClient, String uri) {
+        // Unfortunately we might have duplicate history records for the same URL.
+        final Cursor cursor;
+        try {
+            cursor = contentProviderClient.query(
+                    mHistoryUriWithProfile
+                            .buildUpon()
+                            .appendQueryParameter(BrowserContract.PARAM_LIMIT, "1")
+                            .build(),
+                    new String[]{
+                            History.GUID,
+                    },
+                    History.URL + "= ?",
+                    new String[]{uri}, History.DATE_LAST_VISITED + " DESC"
+            );
+        } catch (RemoteException e) {
+            // Won't happen, we control the implementation.
+            throw new IllegalStateException("Unexpected RemoteException", e);
+        }
+
+        if (cursor == null) {
+            return null;
+        }
+
+        try {
+            if (!cursor.moveToFirst()) {
+                return null;
+            }
+
+            final int historyGUIDCol = cursor.getColumnIndexOrThrow(History.GUID);
+            return cursor.getString(historyGUIDCol);
+        } finally {
+            cursor.close();
+        }
+    }
+
     /**
      * Load a favicon from the omnijar.
      * @return A ConsumedInputStream containing the bytes loaded from omnijar. This must be a format
      *         compatible with the favicon decoder (most probably a PNG or ICO file).
      */
     private static ConsumedInputStream getDefaultFaviconFromPath(Context context, String name) {
         final int faviconId = getFaviconId(name);
         if (faviconId == FAVICON_ID_NOT_FOUND) {
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -425,16 +425,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'GeckoApp.java',
     'GeckoApplication.java',
     'GeckoJavaSampler.java',
     'GeckoMessageReceiver.java',
     'GeckoProfilesProvider.java',
     'GeckoService.java',
     'GeckoUpdateReceiver.java',
     'GlobalHistory.java',
+    'GlobalPageMetadata.java',
     'GuestSession.java',
     'health/HealthRecorder.java',
     'health/SessionInformation.java',
     'health/StubbedHealthRecorder.java',
     'home/activitystream/ActivityStream.java',
     'home/activitystream/ActivityStreamHomeFragment.java',
     'home/activitystream/ActivityStreamHomeScreen.java',
     'home/activitystream/menu/ActivityStreamContextMenu.java',
--- a/mobile/android/modules/WebsiteMetadata.jsm
+++ b/mobile/android/modules/WebsiteMetadata.jsm
@@ -15,17 +15,24 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 var WebsiteMetadata = {
   /**
    * Asynchronously parse the document extract metadata. A 'Website:Metadata' event with the metadata
    * will be sent.
    */
   parseAsynchronously: function(doc) {
     Task.spawn(function() {
-      let metadata = getMetadata(doc, doc.location.href);
+      let metadata = getMetadata(doc, doc.location.href, {
+        image_url: metadataRules['image_url']
+      });
+
+      // No metadata was extracted, so don't bother sending it.
+      if (Object.keys(metadata).length === 0) {
+        return;
+      }
 
       let msg = {
         type: 'Website:Metadata',
         location: doc.location.href,
         metadata: metadata,
       };
 
       Messaging.sendRequest(msg);
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.os.RemoteException;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class GlobalPageMetadataTest {
+    @Test
+    public void testQueueing() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+
+        BrowserProvider provider = new BrowserProvider();
+        try {
+            provider.onCreate();
+            ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+            ShadowContentResolver cr = new ShadowContentResolver();
+            ContentProviderClient pageMetadataClient = cr.acquireContentProviderClient(PageMetadata.CONTENT_URI);
+
+            assertEquals(0, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+            // There's not history record for this uri, so test that queueing works.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article'}");
+
+            assertPageMetadataCountForGUID(0, "guid1", pageMetadataClient);
+            assertEquals(1, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+            // Test that queue doesn't duplicate metadata for the same history item.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article'}");
+            assertEquals(1, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+            // Test that queue is limited to 15 metadata items.
+            for (int i = 0; i < 20; i++) {
+                GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org/" + i, false, "{type: 'article'}");
+            }
+            assertEquals(15, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+        } finally {
+            provider.shutdown();
+        }
+    }
+
+    @Test
+    public void testInsertingMetadata() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+
+        // Start listening for events.
+        GlobalPageMetadata.getInstance().init();
+
+        BrowserProvider provider = new BrowserProvider();
+        try {
+            provider.onCreate();
+            ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+            ShadowContentResolver cr = new ShadowContentResolver();
+            ContentProviderClient historyClient = cr.acquireContentProviderClient(BrowserContract.History.CONTENT_URI);
+            ContentProviderClient pageMetadataClient = cr.acquireContentProviderClient(PageMetadata.CONTENT_URI);
+
+            // Insert required history item...
+            ContentValues cv = new ContentValues();
+            cv.put(BrowserContract.History.GUID, "guid1");
+            cv.put(BrowserContract.History.URL, "https://mozilla.org");
+            historyClient.insert(BrowserContract.History.CONTENT_URI, cv);
+
+            // TODO: Main test runner thread finishes before EventDispatcher events are processed...
+            // Fire off a message saying that history has been inserted.
+            // Bundle message = new Bundle();
+            // message.putString(GlobalHistory.EVENT_PARAM_URI, "https://mozilla.org");
+            // EventDispatcher.getInstance().dispatch(GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY, message);
+
+            // For now, let's just try inserting again.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article', description: 'test article'}");
+
+            assertPageMetadataCountForGUID(1, "guid1", pageMetadataClient);
+            assertPageMetadataValues(pageMetadataClient, "guid1", false, "{\"type\":\"article\",\"description\":\"test article\"}");
+
+            // Test that inserting empty metadata deletes existing metadata record.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{}");
+            assertPageMetadataCountForGUID(0, "guid1", pageMetadataClient);
+
+            // Test that inserting new metadata overrides existing metadata record.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", true, "{type: 'article', description: 'test article', image_url: 'https://example.com/test.png'}");
+            assertPageMetadataValues(pageMetadataClient, "guid1", true, "{\"type\":\"article\",\"description\":\"test article\",\"image_url\":\"https:\\/\\/example.com\\/test.png\"}");
+
+            // Insert another history item...
+            cv = new ContentValues();
+            cv.put(BrowserContract.History.GUID, "guid2");
+            cv.put(BrowserContract.History.URL, "https://planet.mozilla.org");
+            historyClient.insert(BrowserContract.History.CONTENT_URI, cv);
+            // Test that empty metadata doesn't get inserted for a new history.
+            GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://planet.mozilla.org", false, "{}");
+
+            assertPageMetadataCountForGUID(0, "guid2", pageMetadataClient);
+
+        } finally {
+            provider.shutdown();
+        }
+    }
+
+    /**
+     * Expects cursor to be at the correct position.
+     */
+    private void assertCursorValues(Cursor cursor, String json, int hasImage, String guid) {
+        assertNotNull(cursor);
+        assertEquals(json, cursor.getString(cursor.getColumnIndexOrThrow(PageMetadata.JSON)));
+        assertEquals(hasImage, cursor.getInt(cursor.getColumnIndexOrThrow(PageMetadata.HAS_IMAGE)));
+        assertEquals(guid, cursor.getString(cursor.getColumnIndexOrThrow(PageMetadata.HISTORY_GUID)));
+    }
+
+    private void assertPageMetadataValues(ContentProviderClient client, String guid, boolean hasImage, String json) {
+        final Cursor cursor;
+
+        try {
+            cursor = client.query(PageMetadata.CONTENT_URI, new String[]{
+                    PageMetadata.HISTORY_GUID,
+                    PageMetadata.HAS_IMAGE,
+                    PageMetadata.JSON,
+                    PageMetadata.DATE_CREATED
+            }, PageMetadata.HISTORY_GUID + " = ?", new String[]{guid}, null);
+        } catch (RemoteException e) {
+            fail();
+            return;
+        }
+
+        assertNotNull(cursor);
+        try {
+            assertEquals(1, cursor.getCount());
+            assertTrue(cursor.moveToFirst());
+            assertCursorValues(cursor, json, hasImage ? 1 : 0, guid);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    private void assertPageMetadataCountForGUID(int expected, String guid, ContentProviderClient client) {
+        final Cursor cursor;
+
+        try {
+            cursor = client.query(PageMetadata.CONTENT_URI, new String[]{
+                    PageMetadata.HISTORY_GUID,
+                    PageMetadata.HAS_IMAGE,
+                    PageMetadata.JSON,
+                    PageMetadata.DATE_CREATED
+            }, PageMetadata.HISTORY_GUID + " = ?", new String[]{guid}, null);
+        } catch (RemoteException e) {
+            fail();
+            return;
+        }
+
+        assertNotNull(cursor);
+        try {
+            assertEquals(expected, cursor.getCount());
+        } finally {
+            cursor.close();
+        }
+    }
+}
\ No newline at end of file