Bug 1201059 - Synchronize catalog of downloadable content from Kinto instance. r=rnewman,mathieu
authorSebastian Kaspari <s.kaspari@gmail.com>
Tue, 22 Mar 2016 11:20:18 +0100
changeset 290894 a8cb004e06e7c61c6ca3676e2c0d33773882e702
parent 290893 435d12d164aa06b543328d2c7300bd57cc2896f5
child 290895 77bdb92ae1497953ff188f77e9279ada73ec0ce9
push id19656
push usergwagner@mozilla.com
push dateMon, 04 Apr 2016 13:43:23 +0000
treeherderb2g-inbound@e99061fde28a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, mathieu
bugs1201059
milestone48.0a1
Bug 1201059 - Synchronize catalog of downloadable content from Kinto instance. r=rnewman,mathieu This patch does a couple of things: * Add a SyncAction for synchronizing the catalog from Kinto * Add a CleanupAction for removing files no longer needed * Migrate the data structure of DownloadContentCatalog from a list to a map. * Move the more complex builder code to its own class: DownloadContentBuilder * Introduce a switchboard expriment for staged rollout (download-content-catalog-sync) MozReview-Commit-ID: D733Xx6LxOb
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java
mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java
mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
mobile/android/base/moz.build
mobile/android/tests/background/junit4/resources/dlc_sync_deleted_item.json
mobile/android/tests/background/junit4/resources/dlc_sync_single_font.json
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContent.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -20,16 +20,17 @@ import org.mozilla.gecko.GeckoProfileDir
 import org.mozilla.gecko.Tabs.TabEvents;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
 import org.mozilla.gecko.feeds.FeedService;
 import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
 import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason;
@@ -1950,16 +1951,19 @@ public class BrowserApp extends GeckoApp
                         @Override
                         public void run() {
                              GeckoPreferences.broadcastStumblerPref(BrowserApp.this);
                         }
                     }, oneSecondInMillis);
                 }
 
                 if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+                    // TODO: Better scheduling of sync action (Bug 1257492)
+                    DownloadContentService.startSync(this);
+
                     DownloadContentService.startVerification(this);
                 }
 
                 FeedService.setup(this);
 
                 super.handleMessage(event, message);
             } else if (event.equals("Gecko:Ready")) {
                 // Handle this message in GeckoApp, but also enable the Settings
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
@@ -4,27 +4,32 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.dlc;
 
 import android.content.Context;
 import android.support.annotation.IntDef;
 import android.util.Log;
 
+import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.background.nativecode.NativeCrypto;
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.IOUtils;
 
 import java.io.BufferedInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
 
 public abstract class BaseAction {
     private static final String LOGTAG = "GeckoDLCBaseAction";
 
     /**
      * Exception indicating a recoverable error has happened. Download of the content will be retried later.
      */
     /* package-private */ static class RecoverableDownloadContentException extends Exception {
@@ -132,9 +137,27 @@ public abstract class BaseAction {
             return true;
         } catch (IOException e) {
             // Recoverable: Just I/O discontinuation
             throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
         } finally {
             IOUtils.safeStreamClose(inputStream);
         }
     }
+
+    protected HttpURLConnection buildHttpURLConnection(String url)
+            throws UnrecoverableDownloadContentException, IOException {
+        // TODO: Implement proxy support (Bug 1209496)
+        try {
+            System.setProperty("http.keepAlive", "true");
+
+            HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+            connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ?
+                    AppConstants.USER_AGENT_FENNEC_TABLET :
+                    AppConstants.USER_AGENT_FENNEC_MOBILE);
+            connection.setRequestMethod("GET");
+            connection.setInstanceFollowRedirects(true);
+            return connection;
+        } catch (MalformedURLException e) {
+            throw new UnrecoverableDownloadContentException(e);
+        }
+    }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java
@@ -0,0 +1,49 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+
+import java.io.File;
+
+/**
+ * CleanupAction: Remove content that is no longer needed.
+ */
+public class CleanupAction extends BaseAction {
+    @Override
+    public void perform(Context context, DownloadContentCatalog catalog) {
+        for (DownloadContent content : catalog.getContentToDelete()) {
+            if (!content.isAssetArchive()) {
+                continue; // We do not know how to clean up this content. But this means we didn't
+                          // download it anyways.
+            }
+
+            try {
+                File file = getDestinationFile(context, content);
+
+                if (!file.exists()) {
+                    // File does not exist. As good as deleting.
+                    catalog.remove(content);
+                    return;
+                }
+
+                if (file.delete()) {
+                    // File has been deleted. Now remove it from the catalog.
+                    catalog.remove(content);
+                }
+            } catch (UnrecoverableDownloadContentException e) {
+                // We can't recover. Pretend the content is removed. It probably never existed in
+                // the first place.
+                catalog.remove(content);
+            } catch (RecoverableDownloadContentException e) {
+                // Try again next time.
+            }
+        }
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
@@ -36,19 +36,16 @@ import java.util.zip.GZIPInputStream;
  */
 public class DownloadAction extends BaseAction {
     private static final String LOGTAG = "DLCDownloadAction";
 
     private static final String CACHE_DIRECTORY = "downloadContent";
 
     private static final String CDN_BASE_URL = "https://mobile.cdn.mozilla.net/";
 
-    private static final int STATUS_OK = 200;
-    private static final int STATUS_PARTIAL_CONTENT = 206;
-
     public interface Callback {
         void onContentDownloaded(DownloadContent content);
     }
 
     private Callback callback;
 
     public DownloadAction(Callback callback) {
         this.callback = callback;
@@ -154,17 +151,17 @@ public class DownloadAction extends Base
             connection = buildHttpURLConnection(source);
 
             final long offset = temporaryFile.exists() ? temporaryFile.length() : 0;
             if (offset > 0) {
                 connection.setRequestProperty("Range", "bytes=" + offset + "-");
             }
 
             final int status = connection.getResponseCode();
-            if (status != STATUS_OK && status != STATUS_PARTIAL_CONTENT) {
+            if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
                 // We are trying to be smart and only retry if this is an error that might resolve in the future.
                 // TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106).
                 if (status >= 500) {
                     // Recoverable: Server errors 5xx
                     throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,
                                                                   "(Recoverable) Download failed. Status code: " + status);
                 } else if (status >= 400) {
                     // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
@@ -174,17 +171,17 @@ public class DownloadAction extends Base
                     // Informational 1xx: They have no meaning to us.
                     // Successful 2xx: We don't know how to handle anything but 200.
                     // Redirection 3xx: HttpClient should have followed redirects if possible. We should not see those errors here.
                     throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
                 }
             }
 
             inputStream = new BufferedInputStream(connection.getInputStream());
-            outputStream = openFile(temporaryFile, status == STATUS_PARTIAL_CONTENT);
+            outputStream = openFile(temporaryFile, status == HttpURLConnection.HTTP_PARTIAL);
 
             IOUtils.copy(inputStream, outputStream);
 
             inputStream.close();
             outputStream.close();
         } catch (IOException e) {
             // Recoverable: Just I/O discontinuation
             throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
@@ -250,34 +247,19 @@ public class DownloadAction extends Base
         return networkInfo != null && networkInfo.isConnected();
     }
 
     protected boolean isActiveNetworkMetered(Context context) {
         return ConnectivityManagerCompat.isActiveNetworkMetered(
                 (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
     }
 
-    protected HttpURLConnection buildHttpURLConnection(String url)
-            throws UnrecoverableDownloadContentException, IOException {
-        // TODO: Implement proxy support (Bug 1209496)
-        try {
-            System.setProperty("http.keepAlive", "true");
+    protected String createDownloadURL(DownloadContent content) {
+        final String location = content.getLocation();
 
-            HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
-            connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ?
-                    AppConstants.USER_AGENT_FENNEC_TABLET :
-                    AppConstants.USER_AGENT_FENNEC_MOBILE);
-            connection.setRequestMethod("GET");
-            return connection;
-        } catch (MalformedURLException e) {
-            throw new UnrecoverableDownloadContentException(e);
-        }
-    }
-
-    protected String createDownloadURL(DownloadContent content) {
         return CDN_BASE_URL + content.getLocation();
     }
 
     protected File createTemporaryFile(Context context, DownloadContent content)
             throws RecoverableDownloadContentException {
         File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
 
         if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
@@ -19,20 +19,41 @@ import android.content.Intent;
 import android.util.Log;
 
 /**
  * Service to handle downloadable content that did not ship with the APK.
  */
 public class DownloadContentService extends IntentService {
     private static final String LOGTAG = "GeckoDLCService";
 
+    /**
+     * Study: Scan the catalog for "new" content available for download.
+     */
     private static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
+
+    /**
+     * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+     */
     private static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
+
+    /**
+     * Download content that has been scheduled during "study" or "verify".
+     */
     private static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
 
+    /**
+     * Sync: Synchronize catalog from a Kinto instance.
+     */
+    private static final String ACTION_SYNCHRONIZE_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.SYNC";
+
+    /**
+     * CleanupAction: Remove content that is no longer needed (e.g. Removed from the catalog after a sync).
+     */
+    private static final String ACTION_CLEANUP_FILES = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.CLEANUP";
+
     public static void startStudy(Context context) {
         Intent intent = new Intent(ACTION_STUDY_CATALOG);
         intent.setComponent(new ComponentName(context, DownloadContentService.class));
         context.startService(intent);
     }
 
     public static void startVerification(Context context) {
         Intent intent = new Intent(ACTION_VERIFY_CONTENT);
@@ -41,16 +62,28 @@ public class DownloadContentService exte
     }
 
     public static void startDownloads(Context context) {
         Intent intent = new Intent(ACTION_DOWNLOAD_CONTENT);
         intent.setComponent(new ComponentName(context, DownloadContentService.class));
         context.startService(intent);
     }
 
+    public static void startSync(Context context) {
+        Intent intent = new Intent(ACTION_SYNCHRONIZE_CATALOG);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
+
+    public static void startCleanup(Context context) {
+        Intent intent = new Intent(ACTION_CLEANUP_FILES);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
+
     private DownloadContentCatalog catalog;
 
     public DownloadContentService() {
         super(LOGTAG);
     }
 
     @Override
     public void onCreate() {
@@ -92,16 +125,20 @@ public class DownloadContentService exte
                     }
                 });
                 break;
 
             case ACTION_VERIFY_CONTENT:
                 action = new VerifyAction();
                 break;
 
+            case ACTION_SYNCHRONIZE_CATALOG:
+                action = new SyncAction();
+                break;
+
             default:
                 Log.e(LOGTAG, "Unknown action: " + intent.getAction());
                 return;
         }
 
         action.perform(this, catalog);
         catalog.persistChanges();
     }
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
@@ -1,41 +1,85 @@
 /* -*- 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.dlc;
 
 import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.text.TextUtils;
 import android.util.Log;
 
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 
 /**
  * Study: Scan the catalog for "new" content available for download.
  */
 public class StudyAction extends BaseAction {
     private static final String LOGTAG = "DLCStudyAction";
 
     public void perform(Context context, DownloadContentCatalog catalog) {
         Log.d(LOGTAG, "Studying catalog..");
 
-        for (DownloadContent content : catalog.getContentWithoutState()) {
+        for (DownloadContent content : catalog.getContentToStudy()) {
+            if (!isMatching(context, content)) {
+                // This content is not for this particular version of the application or system
+                continue;
+            }
+
             if (content.isAssetArchive() && content.isFont()) {
                 catalog.scheduleDownload(content);
 
                 Log.d(LOGTAG, "Scheduled download: " + content);
             }
         }
 
         if (catalog.hasScheduledDownloads()) {
             startDownloads(context);
         }
 
         Log.v(LOGTAG, "Done");
     }
 
+    protected boolean isMatching(Context context, DownloadContent content) {
+        final String androidApiPattern = content.getAndroidApiPattern();
+        if (!TextUtils.isEmpty(androidApiPattern)) {
+            final String apiVersion = String.valueOf(Build.VERSION.SDK_INT);
+            if (apiVersion.matches(androidApiPattern)) {
+                Log.d(LOGTAG, String.format("Android API (%s) does not match pattern: %s", apiVersion, androidApiPattern));
+                return false;
+            }
+        }
+
+        final String appIdPattern = content.getAppIdPattern();
+        if (!TextUtils.isEmpty(appIdPattern)) {
+            final String appId = context.getPackageName();
+            if (!appId.matches(appIdPattern)) {
+                Log.d(LOGTAG, String.format("App ID (%s) does not match pattern: %s", appId, appIdPattern));
+                return false;
+            }
+        }
+
+        final String appVersionPattern = content.getAppVersionPattern();
+        if (!TextUtils.isEmpty(appVersionPattern)) {
+            try {
+                final String appVersion = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
+                if (!appVersion.matches(appVersionPattern)) {
+                    Log.d(LOGTAG, String.format("App version (%s) does not match pattern: %s", appVersion, appVersionPattern));
+                    return false;
+                }
+            } catch (PackageManager.NameNotFoundException e) {
+                throw new AssertionError("Should not happen: Can't get package info of own package");
+            }
+        }
+
+        // There are no patterns or all patterns have matched.
+        return true;
+    }
+
     protected void startDownloads(Context context) {
         DownloadContentService.startDownloads(context);
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
@@ -0,0 +1,254 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.Experiments;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+
+/**
+ * Sync: Synchronize catalog from a Kinto instance.
+ */
+public class SyncAction extends BaseAction {
+    private static final String LOGTAG = "DLCSyncAction";
+
+    private static final String KINTO_KEY_ID = "id";
+    private static final String KINTO_KEY_DELETED = "deleted";
+    private static final String KINTO_KEY_DATA = "data";
+
+    private static final String KINTO_PARAMETER_SINCE = "_since";
+    private static final String KINTO_PARAMETER_FIELDS = "_fields";
+    private static final String KINTO_PARAMETER_SORT = "_sort";
+
+    /**
+     * Kinto endpoint with online version of downloadable content catalog
+     *
+     * Dev instance:
+     * https://kinto-ota.dev.mozaws.net/v1/buckets/dlc/collections/catalog/records
+     */
+    private static final String CATALOG_ENDPOINT = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec-dlc/collections/catalog/records";
+
+    @Override
+    public void perform(Context context, DownloadContentCatalog catalog) {
+        Log.d(LOGTAG, "Synchronizing catalog.");
+
+        if (!isSyncEnabledForClient(context)) {
+            Log.d(LOGTAG, "Sync is not enabled for client. Skipping.");
+            return;
+        }
+
+        boolean cleanupRequired = false;
+        boolean studyRequired = false;
+
+        try {
+            long lastModified = catalog.getLastModified();
+
+            // TODO: Consider using ETag here (Bug 1257459)
+            JSONArray rawCatalog = fetchRawCatalog(lastModified);
+
+            Log.d(LOGTAG, "Server returned " + rawCatalog.length() + " records (since " + lastModified + ")");
+
+            for (int i = 0; i < rawCatalog.length(); i++) {
+                JSONObject object = rawCatalog.getJSONObject(i);
+                String id = object.getString(KINTO_KEY_ID);
+
+                final boolean isDeleted = object.optBoolean(KINTO_KEY_DELETED, false);
+
+                DownloadContent existingContent = catalog.getContentById(id);
+
+                if (isDeleted) {
+                    cleanupRequired |= deleteContent(catalog, id);
+                } else if (existingContent != null) {
+                    studyRequired |= updateContent(catalog, object, existingContent);
+                } else {
+                    studyRequired |= createContent(catalog, object);
+                }
+            }
+        } catch (UnrecoverableDownloadContentException e) {
+            Log.e(LOGTAG, "UnrecoverableDownloadContentException", e);
+        } catch (RecoverableDownloadContentException e) {
+            Log.e(LOGTAG, "RecoverableDownloadContentException");
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "JSONException", e);
+        }
+
+        if (studyRequired) {
+            startStudyAction(context);
+        }
+
+        if (cleanupRequired) {
+            startCleanupAction(context);
+        }
+
+        Log.v(LOGTAG, "Done");
+    }
+
+    protected void startStudyAction(Context context) {
+        DownloadContentService.startStudy(context);
+    }
+
+    protected void startCleanupAction(Context context) {
+        DownloadContentService.startCleanup(context);
+    }
+
+    protected JSONArray fetchRawCatalog(long lastModified)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        HttpURLConnection connection = null;
+
+        try {
+            Uri.Builder builder = Uri.parse(CATALOG_ENDPOINT).buildUpon();
+
+            if (lastModified > 0) {
+                builder.appendQueryParameter(KINTO_PARAMETER_SINCE, String.valueOf(lastModified));
+            }
+            // Only select the fields we are actually going to read.
+            builder.appendQueryParameter(KINTO_PARAMETER_FIELDS,
+                    "attachment.location,original.filename,original.hash,attachment.hash,type,kind,original.size,match");
+
+            // We want to process items in the order they have been modified. This is to ensure that
+            // our last_modified values are correct if we processing is interrupted and not all items
+            // have been processed.
+            builder.appendQueryParameter(KINTO_PARAMETER_SORT, "last_modified");
+
+            connection = buildHttpURLConnection(builder.build().toString());
+
+            // TODO: Read 'Alert' header and EOL message if existing (Bug 1249248)
+
+            // TODO: Read and use 'Backoff' header if available (Bug 1249251)
+
+            // TODO: Add support for Next-Page header (Bug 1257495)
+
+            final int responseCode = connection.getResponseCode();
+
+            if (responseCode != HttpURLConnection.HTTP_OK) {
+                if (responseCode >= 500) {
+                    // A Retry-After header will be added to error responses (>=500), telling the
+                    // client how many seconds it should wait before trying again.
+
+                    // TODO: Read and obey value in "Retry-After" header (Bug 1249249)
+                    throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Server error (" + responseCode + ")");
+                } else if (responseCode == 410) {
+                    // A 410 Gone error response can be returned if the client version is too old,
+                    // or the service had been replaced with a new and better service using a new
+                    // protocol version.
+
+                    // TODO: The server is gone. Stop synchronizing the catalog from this server (Bug 1249248).
+                    throw new UnrecoverableDownloadContentException("Server is gone (410)");
+                } else if (responseCode >= 400) {
+                    // If the HTTP status is >=400 the response contains a JSON response.
+                    logErrorResponse(connection);
+
+                    // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
+                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Catalog sync failed. Status code: " + responseCode);
+                } else if (responseCode < 200) {
+                    // If the HTTP status is <200 the response contains a JSON response.
+                    logErrorResponse(connection);
+
+                    throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,  "Response code: " + responseCode);
+                } else {
+                    // HttpsUrlConnection: -1 (No valid response code)
+                    // Successful 2xx: We don't know how to handle anything but 200.
+                    // Redirection 3xx: We should have followed redirects if possible. We should not see those errors here.
+
+                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Response code: " + responseCode);
+                }
+            }
+
+            return fetchJSONResponse(connection).getJSONArray(KINTO_KEY_DATA);
+        } catch (JSONException | IOException e) {
+            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    private JSONObject fetchJSONResponse(HttpURLConnection connection) throws IOException, JSONException {
+        InputStream inputStream = null;
+
+        try {
+            inputStream = new BufferedInputStream(connection.getInputStream());
+            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            IOUtils.copy(inputStream, outputStream);
+            return new JSONObject(outputStream.toString("UTF-8"));
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+        }
+    }
+
+    protected boolean updateContent(DownloadContentCatalog catalog, JSONObject object, DownloadContent existingContent)
+            throws JSONException {
+        DownloadContent content = existingContent.buildUpon()
+                .updateFromKinto(object)
+                .build();
+
+        if (existingContent.getLastModified() >= content.getLastModified()) {
+            Log.d(LOGTAG, "Item has not changed: " + content);
+            return false;
+        }
+
+        catalog.update(content);
+
+        return true;
+    }
+
+    protected boolean createContent(DownloadContentCatalog catalog, JSONObject object) throws JSONException {
+        DownloadContent content = new DownloadContentBuilder()
+                .updateFromKinto(object)
+                .build();
+
+        catalog.add(content);
+
+        return true;
+    }
+
+    protected boolean deleteContent(DownloadContentCatalog catalog, String id) {
+        DownloadContent content = catalog.getContentById(id);
+        if (content == null) {
+            return false;
+        }
+
+        catalog.markAsDeleted(content);
+
+        return true;
+    }
+
+    protected boolean isSyncEnabledForClient(Context context) {
+        // Sync action is behind a switchboard flag for staged rollout.
+        return Experiments.isInExperimentLocal(context, Experiments.DOWNLOAD_CONTENT_CATALOG_SYNC);
+    }
+
+    private void logErrorResponse(HttpURLConnection connection) {
+        try {
+            JSONObject error = fetchJSONResponse(connection);
+
+            Log.w(LOGTAG, "Server returned error response:");
+            Log.w(LOGTAG, "- Code:    " + error.getInt("code"));
+            Log.w(LOGTAG, "- Errno:   " + error.getInt("errno"));
+            Log.w(LOGTAG, "- Error:   " + error.optString("error", "-"));
+            Log.w(LOGTAG, "- Message: " + error.optString("message", "-"));
+            Log.w(LOGTAG, "- Info:    " + error.optString("info", "-"));
+        } catch (JSONException | IOException e) {
+            Log.w(LOGTAG, "Could not fetch error response", e);
+        }
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
@@ -2,42 +2,29 @@
  * 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.dlc.catalog;
 
 import android.support.annotation.IntDef;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.annotation.StringDef;
 
-import org.json.JSONException;
-import org.json.JSONObject;
-
 public class DownloadContent {
-    private static final String KEY_ID = "id";
-    private static final String KEY_LOCATION = "location";
-    private static final String KEY_FILENAME = "filename";
-    private static final String KEY_CHECKSUM = "checksum";
-    private static final String KEY_DOWNLOAD_CHECKSUM = "download_checksum";
-    private static final String KEY_LAST_MODIFIED = "last_modified";
-    private static final String KEY_TYPE = "type";
-    private static final String KEY_KIND = "kind";
-    private static final String KEY_SIZE = "size";
-    private static final String KEY_STATE = "state";
-    private static final String KEY_FAILURES = "failures";
-    private static final String KEY_LAST_FAILURE_TYPE = "last_failure_type";
-
-    @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED})
+    @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED, STATE_UPDATED, STATE_DELETED})
     public @interface State {}
     public static final int STATE_NONE = 0;
     public static final int STATE_SCHEDULED = 1;
     public static final int STATE_DOWNLOADED = 2;
     public static final int STATE_FAILED = 3; // Permanently failed for this version of the content
     public static final int STATE_IGNORED = 4;
+    public static final int STATE_UPDATED = 5;
+    public static final int STATE_DELETED = 6;
 
     @StringDef({TYPE_ASSET_ARCHIVE})
     public @interface Type {}
     public static final String TYPE_ASSET_ARCHIVE = "asset-archive";
 
     @StringDef({KIND_FONT})
     public @interface Kind {}
     public static final String KIND_FONT = "font";
@@ -46,48 +33,67 @@ public class DownloadContent {
     private final String location;
     private final String filename;
     private final String checksum;
     private final String downloadChecksum;
     private final long lastModified;
     private final String type;
     private final String kind;
     private final long size;
+    private final String appVersionPattern;
+    private final String androidApiPattern;
+    private final String appIdPattern;
     private int state;
     private int failures;
     private int lastFailureType;
 
-    private DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
+    /* package-private */ DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
                             @NonNull String checksum, @NonNull String downloadChecksum, @NonNull long lastModified,
-                            @NonNull String type, @NonNull String kind, long size) {
+                            @NonNull String type, @NonNull String kind, long size, int failures, int lastFailureType,
+                            @Nullable String appVersionPattern, @Nullable String androidApiPattern, @Nullable String appIdPattern) {
         this.id = id;
         this.location = location;
         this.filename = filename;
         this.checksum = checksum;
         this.downloadChecksum = downloadChecksum;
         this.lastModified = lastModified;
         this.type = type;
         this.kind = kind;
         this.size = size;
         this.state = STATE_NONE;
+        this.failures = failures;
+        this.lastFailureType = lastFailureType;
+        this.appVersionPattern = appVersionPattern;
+        this.androidApiPattern = androidApiPattern;
+        this.appIdPattern = appIdPattern;
     }
 
     public String getId() {
         return id;
     }
 
     /* package-private */ void setState(@State int state) {
         this.state = state;
     }
 
     @State
     public int getState() {
         return state;
     }
 
+    public boolean isStateIn(@State int... states) {
+        for (int state : states) {
+            if (this.state == state) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     @Kind
     public String getKind() {
         return kind;
     }
 
     @Type
     public String getType() {
         return type;
@@ -124,145 +130,47 @@ public class DownloadContent {
     public boolean isAssetArchive() {
         return TYPE_ASSET_ARCHIVE.equals(type);
     }
 
     /* package-private */ int getFailures() {
         return failures;
     }
 
+    /* package-private */ int getLastFailureType() {
+        return lastFailureType;
+    }
+
     /* package-private */ void rememberFailure(int failureType) {
         if (lastFailureType != failureType) {
             lastFailureType = failureType;
             failures = 1;
         } else {
             failures++;
         }
     }
 
     /* package-private */ void resetFailures() {
         failures = 0;
         lastFailureType = 0;
     }
 
-    public static DownloadContent fromJSON(JSONObject object) throws JSONException {
-        return new Builder()
-                .setId(object.getString(KEY_ID))
-                .setLocation(object.getString(KEY_LOCATION))
-                .setFilename(object.getString(KEY_FILENAME))
-                .setChecksum(object.getString(KEY_CHECKSUM))
-                .setDownloadChecksum(object.getString(KEY_DOWNLOAD_CHECKSUM))
-                .setLastModified(object.getLong(KEY_LAST_MODIFIED))
-                .setType(object.getString(KEY_TYPE))
-                .setKind(object.getString(KEY_KIND))
-                .setSize(object.getLong(KEY_SIZE))
-                .setState(object.getInt(KEY_STATE))
-                .setFailures(object.optInt(KEY_FAILURES), object.optInt(KEY_LAST_FAILURE_TYPE))
-                .build();
+    public String getAppVersionPattern() {
+        return appVersionPattern;
+    }
+
+    public String getAndroidApiPattern() {
+        return androidApiPattern;
     }
 
-    public JSONObject toJSON() throws JSONException {
-        JSONObject object = new JSONObject();
-        object.put(KEY_ID, id);
-        object.put(KEY_LOCATION, location);
-        object.put(KEY_FILENAME, filename);
-        object.put(KEY_CHECKSUM, checksum);
-        object.put(KEY_DOWNLOAD_CHECKSUM, downloadChecksum);
-        object.put(KEY_LAST_MODIFIED, lastModified);
-        object.put(KEY_TYPE, type);
-        object.put(KEY_KIND, kind);
-        object.put(KEY_SIZE, size);
-        object.put(KEY_STATE, state);
+    public String getAppIdPattern() {
+        return appIdPattern;
+    }
 
-        if (failures > 0) {
-            object.put(KEY_FAILURES, failures);
-            object.put(KEY_LAST_FAILURE_TYPE, lastFailureType);
-        }
+    public DownloadContentBuilder buildUpon() {
+        return DownloadContentBuilder.buildUpon(this);
+    }
 
-        return object;
-    }
 
     public String toString() {
         return String.format("[%s,%s] %s (%d bytes) %s", getType(), getKind(), getId(), getSize(), getChecksum());
     }
-
-    public static class Builder {
-        private String id;
-        private String location;
-        private String filename;
-        private String checksum;
-        private String downloadChecksum;
-        private long lastModified;
-        private String type;
-        private String kind;
-        private long size;
-        private int state;
-        private int failures;
-        private int lastFailureType;
-
-        public DownloadContent build() {
-            DownloadContent content = new DownloadContent(id, location, filename, checksum, downloadChecksum,
-                                                          lastModified, type, kind, size);
-            content.setState(state);
-            content.failures = failures;
-            content.lastFailureType = lastFailureType;
-
-            return content;
-        }
-
-        public Builder setId(String id) {
-            this.id = id;
-            return this;
-        }
-
-        public Builder setLocation(String location) {
-            this.location = location;
-            return this;
-        }
-
-        public Builder setFilename(String filename) {
-            this.filename = filename;
-            return this;
-        }
-
-        public Builder setChecksum(String checksum) {
-            this.checksum = checksum;
-            return this;
-        }
-
-        public Builder setDownloadChecksum(String downloadChecksum) {
-            this.downloadChecksum = downloadChecksum;
-            return this;
-        }
-
-        public Builder setLastModified(long lastModified) {
-            this.lastModified = lastModified;
-            return this;
-        }
-
-        public Builder setType(String type) {
-            this.type = type;
-            return this;
-        }
-
-        public Builder setKind(String kind) {
-            this.kind = kind;
-            return this;
-        }
-
-        public Builder setSize(long size) {
-            this.size = size;
-            return this;
-        }
-
-        public Builder setState(int state) {
-            this.state = state;
-            return this;
-        }
-
-        /* package-private */ Builder setFailures(int failures, int lastFailureType) {
-            this.failures = failures;
-            this.lastFailureType = lastFailureType;
-
-            return this;
-        }
-    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
@@ -1,156 +1,161 @@
 /* -*- 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.dlc.catalog;
 
+import android.support.v4.util.ArrayMap;
+
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.dlc.catalog.DownloadContent;
 
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 
 /* package-private */ class DownloadContentBootstrap {
-    public static List<DownloadContent> createInitialDownloadContentList() {
+    public static ArrayMap<String, DownloadContent> createInitialDownloadContentList() {
         if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) {
             // We are packaging fonts. There's nothing we want to download;
-            return Collections.emptyList();
+            return new ArrayMap<>();
         }
 
-        return Arrays.asList(
-                new DownloadContent.Builder()
+        List<DownloadContent> initialList = Arrays.asList(
+                new DownloadContentBuilder()
                         .setId("c40929cf-7f4c-fa72-3dc9-12cadf56905d")
                         .setLocation("fonts/ff7ecae7669a51d5fa6a5f8e703278ebda3a68f51bc49c4321bde4438020d639.gz")
                         .setFilename("CharisSILCompact-B.ttf")
                         .setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d")
                         .setDownloadChecksum("ff7ecae7669a51d5fa6a5f8e703278ebda3a68f51bc49c4321bde4438020d639")
                         .setSize(1676072)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("6d265876-85ed-0917-fdc8-baf583ca2cba")
                         .setLocation("fonts/dfb6d583edd27d5e6d91d479e6c8a5706275662c940c65b70911493bb279904a.gz")
                         .setFilename("CharisSILCompact-BI.ttf")
                         .setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b")
                         .setDownloadChecksum("dfb6d583edd27d5e6d91d479e6c8a5706275662c940c65b70911493bb279904a")
                         .setSize(1667812)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("8460dc6d-d129-fd1a-24b6-343dbf6531dd")
                         .setLocation("fonts/5a257ec3c5226e7be0be65e463f5b22eff108da853b9ff7bc47f1733b1ddacf2.gz")
                         .setFilename("CharisSILCompact-I.ttf")
                         .setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767")
                         .setDownloadChecksum("5a257ec3c5226e7be0be65e463f5b22eff108da853b9ff7bc47f1733b1ddacf2")
                         .setSize(1693988)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("c906275c-3747-fe27-426f-6187526a6f06")
                         .setLocation("fonts/cab284228b8dfe8ef46c3f1af70b5b6f9e92878f05e741ecc611e5e750a4a3b3.gz")
                         .setFilename("CharisSILCompact-R.ttf")
                         .setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067")
                         .setDownloadChecksum("cab284228b8dfe8ef46c3f1af70b5b6f9e92878f05e741ecc611e5e750a4a3b3")
                         .setSize(1727656)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("ff5deecc-6ecc-d816-bb51-65face460119")
                         .setLocation("fonts/d95168996dc932e6504cb5448fcb759e0ee6e66c5c8603293b046d28ab589cce.gz")
                         .setFilename("ClearSans-Bold.ttf")
                         .setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70")
                         .setDownloadChecksum("d95168996dc932e6504cb5448fcb759e0ee6e66c5c8603293b046d28ab589cce")
                         .setSize(140136)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("a173d1db-373b-ce42-1335-6b3285cfdebd")
                         .setLocation("fonts/f5e18f4acc4ceaeca9e081b1be79cd6034e0dc7ad683fa240195fd6c838452e0.gz")
                         .setFilename("ClearSans-BoldItalic.ttf")
                         .setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198")
                         .setDownloadChecksum("f5e18f4acc4ceaeca9e081b1be79cd6034e0dc7ad683fa240195fd6c838452e0")
                         .setSize(156124)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("e65c66df-0088-940d-ca5c-207c22118c0e")
                         .setLocation("fonts/56d12114ac15d913d7d9876c698889cd25f26e14966a8bd7424aeb0f61ffaf87.gz")
                         .setFilename("ClearSans-Italic.ttf")
                         .setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68")
                         .setDownloadChecksum("56d12114ac15d913d7d9876c698889cd25f26e14966a8bd7424aeb0f61ffaf87")
                         .setSize(155672)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("25610abb-5dc8-fd75-40e7-990507f010c4")
                         .setLocation("fonts/1fc716662866b9c01e32dda3fc9c54ca3e57de8c6ac523f46305d8ae6c0a9cf4.gz")
                         .setFilename("ClearSans-Light.ttf")
                         .setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a")
                         .setDownloadChecksum("1fc716662866b9c01e32dda3fc9c54ca3e57de8c6ac523f46305d8ae6c0a9cf4")
                         .setSize(145976)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("ffe40339-a096-2262-c3f8-54af75c81fe6")
                         .setLocation("fonts/a29184ec6621dbd3bc6ae1e30bba70c479d1001bca647ea4a205ecb64d5a00a0.gz")
                         .setFilename("ClearSans-Medium.ttf")
                         .setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1")
                         .setDownloadChecksum("a29184ec6621dbd3bc6ae1e30bba70c479d1001bca647ea4a205ecb64d5a00a0")
                         .setSize(148892)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("139a94be-ac69-0264-c9cc-8f2d071fd29d")
                         .setLocation("fonts/a381a3d4060e993af440a7b72fed29fa3a488536cc451d7c435d5fae1256318b.gz")
                         .setFilename("ClearSans-MediumItalic.ttf")
                         .setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7")
                         .setDownloadChecksum("a381a3d4060e993af440a7b72fed29fa3a488536cc451d7c435d5fae1256318b")
                         .setSize(155228)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("b887012a-01e1-7c94-fdcb-ca44d5b974a2")
                         .setLocation("fonts/87dec7f0331e19b293fc510f2764b9bd1b94595ac279cf9414f8d03c5bf34dca.gz")
                         .setFilename("ClearSans-Regular.ttf")
                         .setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b")
                         .setDownloadChecksum("87dec7f0331e19b293fc510f2764b9bd1b94595ac279cf9414f8d03c5bf34dca")
                         .setSize(142572)
                         .setKind("font")
                         .setType("asset-archive")
                         .build(),
 
-                new DownloadContent.Builder()
+                new DownloadContentBuilder()
                         .setId("c8703652-d317-0356-0bf8-95441a5b2c9b")
                         .setLocation("fonts/64300b48b2867e5642212690f0ff9ea3988f47790311c444a81d25213b4102aa.gz")
                         .setFilename("ClearSans-Thin.ttf")
                         .setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a")
                         .setDownloadChecksum("64300b48b2867e5642212690f0ff9ea3988f47790311c444a81d25213b4102aa")
                         .setSize(147004)
                         .setKind("font")
                         .setType("asset-archive")
-                        .build()
-        );
+                        .build());
+
+        ArrayMap<String, DownloadContent> content = new ArrayMap<>();
+        for (DownloadContent currentContent : initialList) {
+            content.put(currentContent.getId(), currentContent);
+        }
+        return content;
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java
@@ -0,0 +1,239 @@
+/* -*- 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.dlc.catalog;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class DownloadContentBuilder {
+    private static final String LOCAL_KEY_ID = "id";
+    private static final String LOCAL_KEY_LOCATION = "location";
+    private static final String LOCAL_KEY_FILENAME = "filename";
+    private static final String LOCAL_KEY_CHECKSUM = "checksum";
+    private static final String LOCAL_KEY_DOWNLOAD_CHECKSUM = "download_checksum";
+    private static final String LOCAL_KEY_LAST_MODIFIED = "last_modified";
+    private static final String LOCAL_KEY_TYPE = "type";
+    private static final String LOCAL_KEY_KIND = "kind";
+    private static final String LOCAL_KEY_SIZE = "size";
+    private static final String LOCAL_KEY_STATE = "state";
+    private static final String LOCAL_KEY_FAILURES = "failures";
+    private static final String LOCAL_KEY_LAST_FAILURE_TYPE = "last_failure_type";
+    private static final String LOCAL_KEY_PATTERN_APP_ID = "pattern_app_id";
+    private static final String LOCAL_KEY_PATTERN_ANDROID_API = "pattern_android_api";
+    private static final String LOCAL_KEY_PATTERN_APP_VERSION = "pattern_app_version";
+
+    private static final String KINTO_KEY_ID = "id";
+    private static final String KINTO_KEY_ATTACHMENT = "attachment";
+    private static final String KINTO_KEY_ORIGINAL = "original";
+    private static final String KINTO_KEY_LOCATION = "location";
+    private static final String KINTO_KEY_FILENAME = "filename";
+    private static final String KINTO_KEY_HASH = "hash";
+    private static final String KINTO_KEY_LAST_MODIFIED = "last_modified";
+    private static final String KINTO_KEY_TYPE = "type";
+    private static final String KINTO_KEY_KIND = "kind";
+    private static final String KINTO_KEY_SIZE = "size";
+    private static final String KINTO_KEY_MATCH = "match";
+    private static final String KINTO_KEY_APP_ID = "appId";
+    private static final String KINTO_KEY_ANDROID_API = "androidApi";
+    private static final String KINTO_KEY_APP_VERSION = "appVersion";
+
+    private String id;
+    private String location;
+    private String filename;
+    private String checksum;
+    private String downloadChecksum;
+    private long lastModified;
+    private String type;
+    private String kind;
+    private long size;
+    private int state;
+    private int failures;
+    private int lastFailureType;
+    private String appVersionPattern;
+    private String androidApiPattern;
+    private String appIdPattern;
+
+    public static DownloadContentBuilder buildUpon(DownloadContent content) {
+        DownloadContentBuilder builder = new DownloadContentBuilder();
+
+        builder.id = content.getId();
+        builder.location = content.getLocation();
+        builder.filename = content.getFilename();
+        builder.checksum = content.getChecksum();
+        builder.downloadChecksum = content.getDownloadChecksum();
+        builder.lastModified = content.getLastModified();
+        builder.type = content.getType();
+        builder.kind = content.getKind();
+        builder.size = content.getSize();
+        builder.state = content.getState();
+        builder.failures = content.getFailures();
+        builder.lastFailureType = content.getLastFailureType();
+
+        return builder;
+    }
+
+    public static DownloadContent fromJSON(JSONObject object) throws JSONException {
+        return new DownloadContentBuilder()
+                .setId(object.getString(LOCAL_KEY_ID))
+                .setLocation(object.getString(LOCAL_KEY_LOCATION))
+                .setFilename(object.getString(LOCAL_KEY_FILENAME))
+                .setChecksum(object.getString(LOCAL_KEY_CHECKSUM))
+                .setDownloadChecksum(object.getString(LOCAL_KEY_DOWNLOAD_CHECKSUM))
+                .setLastModified(object.getLong(LOCAL_KEY_LAST_MODIFIED))
+                .setType(object.getString(LOCAL_KEY_TYPE))
+                .setKind(object.getString(LOCAL_KEY_KIND))
+                .setSize(object.getLong(LOCAL_KEY_SIZE))
+                .setState(object.getInt(LOCAL_KEY_STATE))
+                .setFailures(object.optInt(LOCAL_KEY_FAILURES), object.optInt(LOCAL_KEY_LAST_FAILURE_TYPE))
+                .setAppVersionPattern(object.optString(LOCAL_KEY_PATTERN_APP_VERSION))
+                .setAppIdPattern(object.optString(LOCAL_KEY_PATTERN_APP_ID))
+                .setAndroidApiPattern(object.optString(LOCAL_KEY_PATTERN_ANDROID_API))
+                .build();
+    }
+
+    public static JSONObject toJSON(DownloadContent content) throws JSONException {
+        final JSONObject object = new JSONObject();
+        object.put(LOCAL_KEY_ID, content.getId());
+        object.put(LOCAL_KEY_LOCATION, content.getLocation());
+        object.put(LOCAL_KEY_FILENAME, content.getFilename());
+        object.put(LOCAL_KEY_CHECKSUM, content.getChecksum());
+        object.put(LOCAL_KEY_DOWNLOAD_CHECKSUM, content.getDownloadChecksum());
+        object.put(LOCAL_KEY_LAST_MODIFIED, content.getLastModified());
+        object.put(LOCAL_KEY_TYPE, content.getType());
+        object.put(LOCAL_KEY_KIND, content.getKind());
+        object.put(LOCAL_KEY_SIZE, content.getSize());
+        object.put(LOCAL_KEY_STATE, content.getState());
+        object.put(LOCAL_KEY_PATTERN_APP_VERSION, content.getAppVersionPattern());
+        object.put(LOCAL_KEY_PATTERN_APP_ID, content.getAppIdPattern());
+        object.put(LOCAL_KEY_PATTERN_ANDROID_API, content.getAndroidApiPattern());
+
+        final int failures = content.getFailures();
+        if (failures > 0) {
+            object.put(LOCAL_KEY_FAILURES, failures);
+            object.put(LOCAL_KEY_LAST_FAILURE_TYPE, content.getLastFailureType());
+        }
+
+        return object;
+    }
+
+    public DownloadContent build() {
+        DownloadContent content = new DownloadContent(id, location, filename, checksum,
+                downloadChecksum, lastModified, type, kind, size, failures, lastFailureType,
+                appVersionPattern, androidApiPattern, appIdPattern);
+        content.setState(state);
+
+        return content;
+    }
+
+    public DownloadContentBuilder setId(String id) {
+        this.id = id;
+        return this;
+    }
+
+    public DownloadContentBuilder setLocation(String location) {
+        this.location = location;
+        return this;
+    }
+
+    public DownloadContentBuilder setFilename(String filename) {
+        this.filename = filename;
+        return this;
+    }
+
+    public DownloadContentBuilder setChecksum(String checksum) {
+        this.checksum = checksum;
+        return this;
+    }
+
+    public DownloadContentBuilder setDownloadChecksum(String downloadChecksum) {
+        this.downloadChecksum = downloadChecksum;
+        return this;
+    }
+
+    public DownloadContentBuilder setLastModified(long lastModified) {
+        this.lastModified = lastModified;
+        return this;
+    }
+
+    public DownloadContentBuilder setType(String type) {
+        this.type = type;
+        return this;
+    }
+
+    public DownloadContentBuilder setKind(String kind) {
+        this.kind = kind;
+        return this;
+    }
+
+    public DownloadContentBuilder setSize(long size) {
+        this.size = size;
+        return this;
+    }
+
+    public DownloadContentBuilder setState(int state) {
+        this.state = state;
+        return this;
+    }
+
+    /* package-private */ DownloadContentBuilder setFailures(int failures, int lastFailureType) {
+        this.failures = failures;
+        this.lastFailureType = lastFailureType;
+
+        return this;
+    }
+
+    public DownloadContentBuilder setAppVersionPattern(String appVersionPattern) {
+        this.appVersionPattern = appVersionPattern;
+        return this;
+    }
+
+    public DownloadContentBuilder setAndroidApiPattern(String androidApiPattern) {
+        this.androidApiPattern = androidApiPattern;
+        return this;
+    }
+
+    public DownloadContentBuilder setAppIdPattern(String appIdPattern) {
+        this.appIdPattern = appIdPattern;
+        return this;
+    }
+
+    public DownloadContentBuilder updateFromKinto(JSONObject object)  throws JSONException {
+        final String objectId = object.getString(KINTO_KEY_ID);
+
+        if (TextUtils.isEmpty(id)) {
+            // New object without an id yet
+            id = objectId;
+        } else if (!id.equals(objectId)) {
+            throw new JSONException(String.format("Record ids do not match: Expected=%s, Actual=%s", id, objectId));
+        }
+
+        setType(object.getString(KINTO_KEY_TYPE));
+        setKind(object.getString(KINTO_KEY_KIND));
+        setLastModified(object.getLong(KINTO_KEY_LAST_MODIFIED));
+
+        JSONObject original = object.getJSONObject(KINTO_KEY_ORIGINAL);
+
+        setFilename(original.getString(KINTO_KEY_FILENAME));
+        setChecksum(original.getString(KINTO_KEY_HASH));
+        setSize(original.getLong(KINTO_KEY_SIZE));
+
+        JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
+
+        setLocation(attachment.getString(KINTO_KEY_LOCATION));
+        setDownloadChecksum(attachment.getString(KINTO_KEY_HASH));
+
+        JSONObject match = object.optJSONObject(KINTO_KEY_MATCH);
+        if (match != null) {
+            setAndroidApiPattern(match.optString(KINTO_KEY_ANDROID_API));
+            setAppIdPattern(match.optString(KINTO_KEY_APP_ID));
+            setAppVersionPattern(match.optString(KINTO_KEY_APP_VERSION));
+        }
+
+        return this;
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
@@ -1,112 +1,149 @@
 /* -*- 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.dlc.catalog;
 
 import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v4.util.ArrayMap;
 import android.support.v4.util.AtomicFile;
 import android.util.Log;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 /**
  * Catalog of downloadable content (DLC).
  *
  * Changing elements returned by the catalog should be guarded by the catalog instance to guarantee visibility when
  * persisting changes.
  */
 public class DownloadContentCatalog {
     private static final String LOGTAG = "GeckoDLCCatalog";
     private static final String FILE_NAME = "download_content_catalog";
 
     private static final String JSON_KEY_CONTENT = "content";
+
     private static final int MAX_FAILURES_UNTIL_PERMANENTLY_FAILED = 10;
 
-    private final AtomicFile file;          // Guarded by 'file'
-    private List<DownloadContent> content;  // Guarded by 'this'
-    private boolean hasLoadedCatalog;       // Guarded by 'this
-    private boolean hasCatalogChanged;      // Guarded by 'this'
+    private final AtomicFile file; // Guarded by 'file'
+
+    private ArrayMap<String, DownloadContent> content; // Guarded by 'this'
+    private boolean hasLoadedCatalog; // Guarded by 'this
+    private boolean hasCatalogChanged; // Guarded by 'this'
 
     public DownloadContentCatalog(Context context) {
         this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
 
         startLoadFromDisk();
     }
 
     // For injecting mocked AtomicFile objects during test
     protected DownloadContentCatalog(AtomicFile file) {
-        this.content = Collections.emptyList();
+        this.content = new ArrayMap<>();
         this.file = file;
     }
 
-    public synchronized List<DownloadContent> getContentWithoutState() {
+    public List<DownloadContent> getContentToStudy() {
+        return filterByState(DownloadContent.STATE_NONE, DownloadContent.STATE_UPDATED);
+    }
+
+    public List<DownloadContent> getContentToDelete() {
+        return filterByState(DownloadContent.STATE_DELETED);
+    }
+
+    public List<DownloadContent> getDownloadedContent() {
+        return filterByState(DownloadContent.STATE_DOWNLOADED);
+    }
+
+    public List<DownloadContent> getScheduledDownloads() {
+        return filterByState(DownloadContent.STATE_SCHEDULED);
+    }
+
+    private synchronized List<DownloadContent> filterByState(@DownloadContent.State int... filterStates) {
         awaitLoadingCatalogLocked();
 
-        List<DownloadContent> contentWithoutState = new ArrayList<>();
+        List<DownloadContent> filteredContent = new ArrayList<>();
 
-        for (DownloadContent content : this.content) {
-            if (DownloadContent.STATE_NONE == content.getState()) {
-                contentWithoutState.add(content);
+        for (DownloadContent currentContent : content.values()) {
+            if (currentContent.isStateIn(filterStates)) {
+                filteredContent.add(currentContent);
             }
         }
 
-        return contentWithoutState;
+        return filteredContent;
+    }
+
+    public boolean hasScheduledDownloads() {
+        return !filterByState(DownloadContent.STATE_SCHEDULED).isEmpty();
+    }
+
+    public synchronized void add(DownloadContent newContent) {
+        awaitLoadingCatalogLocked();
+
+        content.put(newContent.getId(), newContent);
+        hasCatalogChanged = true;
     }
 
-    public synchronized List<DownloadContent> getDownloadedContent() {
+    public synchronized void update(DownloadContent changedContent) {
+        awaitLoadingCatalogLocked();
+
+        if (!content.containsKey(changedContent.getId())) {
+            Log.w(LOGTAG, "Did not find content with matching id (" + changedContent.getId() + ") to update");
+            return;
+        }
+
+        changedContent.setState(DownloadContent.STATE_UPDATED);
+        changedContent.resetFailures();
+
+        content.put(changedContent.getId(), changedContent);
+        hasCatalogChanged = true;
+    }
+
+    public synchronized void remove(DownloadContent removedContent) {
         awaitLoadingCatalogLocked();
 
-        List<DownloadContent> downloadedContent = new ArrayList<>();
-        for (DownloadContent content : this.content) {
-            if (DownloadContent.STATE_DOWNLOADED == content.getState()) {
-                downloadedContent.add(content);
+        if (!content.containsKey(removedContent.getId())) {
+            Log.w(LOGTAG, "Did not find content with matching id (" + removedContent.getId() + ") to remove");
+            return;
+        }
+
+        content.remove(removedContent.getId());
+    }
+
+    @Nullable
+    public synchronized DownloadContent getContentById(String id) {
+        return content.get(id);
+    }
+
+    public synchronized long getLastModified() {
+        awaitLoadingCatalogLocked();
+
+        long lastModified = 0;
+
+        for (DownloadContent currentContent : content.values()) {
+            if (currentContent.getLastModified() > lastModified) {
+                lastModified = currentContent.getLastModified();
             }
         }
 
-        return downloadedContent;
-    }
-
-    public synchronized List<DownloadContent> getScheduledDownloads() {
-        awaitLoadingCatalogLocked();
-
-        List<DownloadContent> scheduledContent = new ArrayList<>();
-        for (DownloadContent content : this.content) {
-            if (DownloadContent.STATE_SCHEDULED == content.getState()) {
-                scheduledContent.add(content);
-            }
-        }
-
-        return scheduledContent;
-    }
-
-    public synchronized boolean hasScheduledDownloads() {
-        awaitLoadingCatalogLocked();
-
-        for (DownloadContent content : this.content) {
-            if (DownloadContent.STATE_SCHEDULED == content.getState()) {
-                return true;
-            }
-        }
-
-        return false;
+        return lastModified;
     }
 
     public synchronized void scheduleDownload(DownloadContent content) {
         content.setState(DownloadContent.STATE_SCHEDULED);
         hasCatalogChanged = true;
     }
 
     public synchronized void markAsDownloaded(DownloadContent content) {
@@ -120,16 +157,21 @@ public class DownloadContentCatalog {
         hasCatalogChanged = true;
     }
 
     public synchronized void markAsIgnored(DownloadContent content) {
         content.setState(DownloadContent.STATE_IGNORED);
         hasCatalogChanged = true;
     }
 
+    public synchronized void markAsDeleted(DownloadContent content) {
+        content.setState(DownloadContent.STATE_DELETED);
+        hasCatalogChanged = true;
+    }
+
     public synchronized void rememberFailure(DownloadContent content, int failureType) {
         if (content.getFailures() >= MAX_FAILURES_UNTIL_PERMANENTLY_FAILED) {
             Log.d(LOGTAG, "Maximum number of failures reached. Marking content has permanently failed.");
 
             markAsPermanentlyFailed(content);
         } else {
             content.rememberFailure(failureType);
             hasCatalogChanged = true;
@@ -170,53 +212,54 @@ public class DownloadContentCatalog {
 
     protected synchronized void loadFromDisk() {
         Log.d(LOGTAG, "Loading from disk");
 
         if (hasLoadedCatalog) {
             return;
         }
 
-        List<DownloadContent> content = new ArrayList<>();
+        ArrayMap<String, DownloadContent> loadedContent = new ArrayMap<>();
 
         try {
             JSONObject catalog;
 
             synchronized (file) {
                 catalog = new JSONObject(new String(file.readFully(), "UTF-8"));
             }
 
             JSONArray array = catalog.getJSONArray(JSON_KEY_CONTENT);
             for (int i = 0; i < array.length(); i++) {
-                content.add(DownloadContent.fromJSON(array.getJSONObject(i)));
+                DownloadContent currentContent = DownloadContentBuilder.fromJSON(array.getJSONObject(i));
+                loadedContent.put(currentContent.getId(), currentContent);
             }
         } catch (FileNotFoundException e) {
             Log.d(LOGTAG, "Catalog file does not exist: Bootstrapping initial catalog");
-            content = DownloadContentBootstrap.createInitialDownloadContentList();
+            loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
         } catch (JSONException e) {
             Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e);
             // Catalog seems to be broken. Re-create catalog:
-            content = DownloadContentBootstrap.createInitialDownloadContentList();
+            loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
             hasCatalogChanged = true; // Indicate that we want to persist the new catalog
         } catch (UnsupportedEncodingException e) {
             AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
             error.initCause(e);
             throw error;
         } catch (IOException e) {
             Log.d(LOGTAG, "Can't read catalog due to IOException", e);
         }
 
-        onCatalogLoaded(content);
+        onCatalogLoaded(loadedContent);
 
         notifyAll();
 
         Log.d(LOGTAG, "Loaded " + content.size() + " elements");
     }
 
-    protected void onCatalogLoaded(List<DownloadContent> content) {
+    protected void onCatalogLoaded(ArrayMap<String, DownloadContent> content) {
         this.content = content;
         this.hasLoadedCatalog = true;
     }
 
     protected synchronized void writeToDisk() {
         if (!hasCatalogChanged) {
             Log.v(LOGTAG, "Not persisting: Catalog has not changed");
             return;
@@ -226,18 +269,18 @@ public class DownloadContentCatalog {
 
         FileOutputStream outputStream = null;
 
         synchronized (file) {
             try {
                 outputStream = file.startWrite();
 
                 JSONArray array = new JSONArray();
-                for (DownloadContent content : this.content) {
-                    array.put(content.toJSON());
+                for (DownloadContent currentContent : content.values()) {
+                    array.put(DownloadContentBuilder.toJSON(currentContent));
                 }
 
                 JSONObject catalog = new JSONObject();
                 catalog.put(JSON_KEY_CONTENT, array);
 
                 outputStream.write(catalog.toString().getBytes("UTF-8"));
 
                 file.finishWrite(outputStream);
--- a/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
@@ -37,16 +37,19 @@ public class Experiments {
     public static final String CONTENT_NOTIFICATIONS_5PM = "content-notifications-5pm";
 
     // Onboarding: "Features and Story". These experiments are determined
     // on the client, they are not part of the server config.
     public static final String ONBOARDING2_A = "onboarding2-a"; // Control: Single (blue) welcome screen
     public static final String ONBOARDING2_B = "onboarding2-b"; // 4 static Feature slides
     public static final String ONBOARDING2_C = "onboarding2-c"; // 4 static + 1 clickable (Data saving) Feature slides
 
+    // Synchronizing the catalog of downloadable content from Kinto
+    public static final String DOWNLOAD_CONTENT_CATALOG_SYNC = "download-content-catalog-sync";
+
     public static final String PREF_ONBOARDING_VERSION = "onboarding_version";
 
     private static volatile Boolean disabled = null;
 
     /**
      * Determines whether Switchboard is disabled by the MOZ_DISABLE_SWITCHBOARD
      * environment variable. We need to read this value from the intent string
      * extra because environment variables from our test harness aren't set
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -247,20 +247,22 @@ gbjar.sources += ['java/org/mozilla/geck
     'db/URLMetadataTable.java',
     'DevToolsAuthHelper.java',
     'distribution/Distribution.java',
     'distribution/ReferrerDescriptor.java',
     'distribution/ReferrerReceiver.java',
     'dlc/BaseAction.java',
     'dlc/catalog/DownloadContent.java',
     'dlc/catalog/DownloadContentBootstrap.java',
+    'dlc/catalog/DownloadContentBuilder.java',
     'dlc/catalog/DownloadContentCatalog.java',
     'dlc/DownloadAction.java',
     'dlc/DownloadContentService.java',
     'dlc/StudyAction.java',
+    'dlc/SyncAction.java',
     'dlc/VerifyAction.java',
     'DoorHangerPopup.java',
     'DownloadsIntegration.java',
     'DynamicToolbar.java',
     'EditBookmarkDialog.java',
     'EventDispatcher.java',
     'favicons/cache/FaviconCache.java',
     'favicons/cache/FaviconCacheElement.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/dlc_sync_deleted_item.json
@@ -0,0 +1,8 @@
+{
+  "data":[
+    {
+      "id":"c906275c-3747-fe27-426f-6187526a6f06",
+      "deleted": true
+    }
+  ]
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/dlc_sync_single_font.json
@@ -0,0 +1,23 @@
+{
+  "data":[
+    {
+      "kind":"font",
+      "original": {
+        "mimetype":"application/x-font-ttf",
+        "filename":"CharisSILCompact-R.ttf",
+        "hash":"4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067",
+        "size":1727656
+      },
+      "last_modified":1455710632607,
+      "attachment": {
+        "mimetype":"application/x-gzip",
+        "size":548720,
+        "hash":"960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e",
+        "location":"/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz",
+        "filename":"CharisSILCompact-R.ttf.gz"
+      },
+      "type":"asset-archive",
+      "id":"c906275c-3747-fe27-426f-6187526a6f06"
+    }
+  ]
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
@@ -7,16 +7,17 @@ package org.mozilla.gecko.dlc;
 
 import android.content.Context;
 
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 import org.robolectric.RuntimeEnvironment;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.net.HttpURLConnection;
@@ -75,17 +76,17 @@ public class TestDownloadAction {
      * Scenario: Content is scheduled for download but already exists locally (with correct checksum).
      *
      * Verify that:
      *  * No download is performed for existing file
      *  * Content is marked as downloaded in the catalog
      */
     @Test
     public void testExistingAndVerifiedFilesAreNotDownloadedAgain() throws Exception {
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
         DownloadAction action = spy(new DownloadAction(null));
         doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
 
         File file = mock(File.class);
@@ -144,17 +145,17 @@ public class TestDownloadAction {
      * Scenario: A successful download has been performed.
      *
      * Verify that:
      *  * The content will be extracted to the destination
      *  * The content is marked as downloaded in the catalog
      */
     @Test
     public void testSuccessfulDownloadsAreMarkedAsDownloaded() throws Exception {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
         DownloadAction action = spy(new DownloadAction(null));
@@ -181,17 +182,17 @@ public class TestDownloadAction {
      *
      * Verify that:
      *  * Range header is set in request
      *  * Content will be appended to existing file
      *  * Content will be marked as downloaded in catalog
      */
     @Test
     public void testResumingDownloadFromExistingFile() throws Exception {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setSize(4223)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
@@ -229,17 +230,17 @@ public class TestDownloadAction {
      * Scenario: Download fails with IOException.
      *
      * Verify that:
      *  * Partially downloaded file will not be deleted
      *  * Content will not be marked as downloaded in catalog
      */
     @Test
     public void testTemporaryFileIsNotDeletedAfterDownloadAborted() throws Exception {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setSize(4223)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
@@ -270,17 +271,17 @@ public class TestDownloadAction {
      *
      * Verify that:
      *  * No download request is made
      *  * File is treated as completed and will be verified and extracted
      *  * Content is marked as downloaded in catalog
      */
     @Test
     public void testNoRequestIsSentIfFileIsAlreadyComplete() throws Exception {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setSize(1337L)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
@@ -309,17 +310,17 @@ public class TestDownloadAction {
      *
      * Verify that:
      *  * Downloaded file is deleted
      *  * File will not be extracted
      *  * Content is not marked as downloaded in the catalog
      */
     @Test
     public void testTemporaryFileWillBeDeletedIfVerificationFails() throws Exception {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setSize(1337L)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
 
@@ -491,17 +492,17 @@ public class TestDownloadAction {
         verify(catalog, times(11)).rememberFailure(eq(content), anyInt());
     }
 
     private DownloadContent createFont() {
         return createFontWithSize(102400L);
     }
 
     private DownloadContent createFontWithSize(long size) {
-        return new DownloadContent.Builder()
+        return new DownloadContentBuilder()
                 .setKind(DownloadContent.KIND_FONT)
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setSize(size)
                 .build();
     }
 
     private DownloadContentCatalog mockCatalogWithScheduledDownloads(DownloadContent... content) {
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
@@ -6,16 +6,17 @@
 package org.mozilla.gecko.dlc;
 
 import android.content.Context;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 import org.robolectric.RuntimeEnvironment;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 
 import static org.mockito.Mockito.any;
@@ -35,45 +36,45 @@ public class TestStudyAction {
      *
      * Verify that:
      *  * No download is scheduled
      *  * Download action is not started
      */
     @Test
     public void testPerformWithEmptyCatalog() {
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
-        when(catalog.getContentWithoutState()).thenReturn(new ArrayList<DownloadContent>());
+        when(catalog.getContentToStudy()).thenReturn(new ArrayList<DownloadContent>());
 
         StudyAction action = spy(new StudyAction());
         action.perform(RuntimeEnvironment.application, catalog);
 
-        verify(catalog).getContentWithoutState();
+        verify(catalog).getContentToStudy();
         verify(catalog, never()).markAsDownloaded(any(DownloadContent.class));
         verify(action, never()).startDownloads(any(Context.class));
     }
 
     /**
      * Scenario: Catalog contains two items that have not been downloaded yet.
      *
      * Verify that:
      *  * Both items are scheduled to be downloaded
      */
     @Test
     public void testPerformWithNewContent() {
-        DownloadContent content1 = new DownloadContent.Builder()
+        DownloadContent content1 = new DownloadContentBuilder()
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setKind(DownloadContent.KIND_FONT)
                 .build();
-        DownloadContent content2 = new DownloadContent.Builder()
+        DownloadContent content2 = new DownloadContentBuilder()
                 .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
                 .setKind(DownloadContent.KIND_FONT)
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
-        when(catalog.getContentWithoutState()).thenReturn(Arrays.asList(content1, content2));
+        when(catalog.getContentToStudy()).thenReturn(Arrays.asList(content1, content2));
 
         StudyAction action = spy(new StudyAction());
         action.perform(RuntimeEnvironment.application, catalog);
 
         verify(catalog).scheduleDownload(content1);
         verify(catalog).scheduleDownload(content2);
     }
 
@@ -97,22 +98,22 @@ public class TestStudyAction {
     /**
      * Scenario: Catalog contains unknown content.
      *
      * Verify that:
      *  * Unknown content is not scheduled for download.
      */
     @Test
     public void testPerformWithUnknownContent() {
-        DownloadContent content = new DownloadContent.Builder()
+        DownloadContent content = new DownloadContentBuilder()
                 .setType("Unknown-Type")
                 .setKind("Unknown-Kind")
                 .build();
 
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
-        when(catalog.getContentWithoutState()).thenReturn(Collections.singletonList(content));
+        when(catalog.getContentToStudy()).thenReturn(Collections.singletonList(content));
 
         StudyAction action = spy(new StudyAction());
         action.perform(RuntimeEnvironment.application, catalog);
 
         verify(catalog, never()).scheduleDownload(content);
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java
@@ -0,0 +1,254 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.AtomicFile;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.IOUtils;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyLong;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * SyncAction: Synchronize catalog from a (mocked) Kinto instance.
+ */
+@RunWith(TestRunner.class)
+public class TestSyncAction {
+    /**
+     * Scenario: The server returns an empty record set.
+     */
+    @Test
+    public void testEmptyResult() throws Exception {
+        SyncAction action = spy(new SyncAction());
+        doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+        doReturn(new JSONArray()).when(action).fetchRawCatalog(anyLong());
+
+        action.perform(RuntimeEnvironment.application, mockCatalog());
+
+        verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+        verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+        verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+        verify(action, never()).startStudyAction(anyContext());
+    }
+
+    /**
+     * Scenario: The server returns an item that is not in the catalog yet.
+     */
+    @Test
+    public void testAddingNewContent() throws Exception {
+        SyncAction action = spy(new SyncAction());
+        doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+        doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+        DownloadContentCatalog catalog = mockCatalog();
+
+        action.perform(RuntimeEnvironment.application, catalog);
+
+        // A new content item has been created
+        verify(action).createContent(anyCatalog(), anyJSONObject());
+
+        // No content item has been updated or deleted
+        verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+        verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+        // A new item has been added to the catalog
+        ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+        verify(catalog).add(captor.capture());
+
+        // The item matches the values from the server response
+        DownloadContent content = captor.getValue();
+        Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+        Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+        Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+        Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+        Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+        Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+        Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+        Assert.assertEquals(1455710632607L, content.getLastModified());
+        Assert.assertEquals(1727656L, content.getSize());
+        Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+    }
+
+    /**
+     * Scenario: The catalog contains one item and the server returns a new version.
+     */
+    @Test
+    public void testUpdatingExistingContent() throws Exception{
+        SyncAction action = spy(new SyncAction());
+        doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+        doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+        DownloadContent existingContent = createTestContent("c906275c-3747-fe27-426f-6187526a6f06");
+        DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+        action.perform(RuntimeEnvironment.application, catalog);
+
+        // A content item has been updated
+        verify(action).updateContent(anyCatalog(), anyJSONObject(), eq(existingContent));
+
+        // No content item has been created or deleted
+        verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+        verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+        // An item has been updated in the catalog
+        ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+        verify(catalog).update(captor.capture());
+
+        // The item has the new values from the sever response
+        DownloadContent content = captor.getValue();
+        Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+        Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+        Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+        Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+        Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+        Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+        Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+        Assert.assertEquals(1455710632607L, content.getLastModified());
+        Assert.assertEquals(1727656L, content.getSize());
+        Assert.assertEquals(DownloadContent.STATE_UPDATED, content.getState());
+    }
+
+    /**
+     * Scenario: Catalog contains one item and the server returns that it has been deleted.
+     */
+    @Test
+    public void testDeletingExistingContent() throws Exception {
+        SyncAction action = spy(new SyncAction());
+        doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+        doReturn(fromFile("dlc_sync_deleted_item.json")).when(action).fetchRawCatalog(anyLong());
+
+        final String id = "c906275c-3747-fe27-426f-6187526a6f06";
+        DownloadContent existingContent = createTestContent(id);
+        DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+        action.perform(RuntimeEnvironment.application, catalog);
+
+        // A content item has been deleted
+        verify(action).deleteContent(anyCatalog(), eq(id));
+
+        // No content item has been created or updated
+        verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+        verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+
+        // An item has been marked for deletion in the catalog
+        ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+        verify(catalog).markAsDeleted(captor.capture());
+
+        DownloadContent content = captor.getValue();
+        Assert.assertEquals(id, content.getId());
+
+        List<DownloadContent> contentToDelete = catalog.getContentToDelete();
+        Assert.assertEquals(1, contentToDelete.size());
+        Assert.assertEquals(id, contentToDelete.get(0).getId());
+    }
+
+    /**
+     * Create a DownloadContent object with arbitrary data.
+     */
+    private DownloadContent createTestContent(String id) {
+        return new DownloadContentBuilder()
+                .setId(id)
+                .setLocation("/somewhere/something")
+                .setFilename("some.file")
+                .setChecksum("Some-checksum")
+                .setDownloadChecksum("Some-download-checksum")
+                .setLastModified(4223)
+                .setType("Some-type")
+                .setKind("Some-kind")
+                .setSize(27)
+                .setState(DownloadContent.STATE_SCHEDULED)
+                .build();
+    }
+
+    /**
+     * Create a Kinto response from a JSON file.
+     */
+    private JSONArray fromFile(String fileName) throws IOException, JSONException {
+        URL url = getClass().getResource("/" + fileName);
+        if (url == null) {
+            throw new FileNotFoundException(fileName);
+        }
+
+        InputStream inputStream = null;
+        ByteArrayOutputStream  outputStream = null;
+
+        try {
+            inputStream = new BufferedInputStream(new FileInputStream(url.getPath()));
+            outputStream = new ByteArrayOutputStream();
+
+            IOUtils.copy(inputStream, outputStream);
+
+            JSONObject object = new JSONObject(outputStream.toString());
+
+            return object.getJSONArray("data");
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+            IOUtils.safeStreamClose(outputStream);
+        }
+    }
+
+    private static class MockedContentCatalog extends DownloadContentCatalog {
+        public MockedContentCatalog(DownloadContent content) {
+            super(mock(AtomicFile.class));
+
+            ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+            map.put(content.getId(), content);
+
+            onCatalogLoaded(map);
+        }
+    }
+
+    private DownloadContentCatalog mockCatalog() {
+        return mock(DownloadContentCatalog.class);
+    }
+
+    private DownloadContentCatalog anyCatalog() {
+        return any(DownloadContentCatalog.class);
+    }
+
+    private JSONObject anyJSONObject() {
+        return any(JSONObject.class);
+    }
+
+    private DownloadContent anyContent() {
+        return any(DownloadContent.class);
+    }
+
+    private Context anyContext() {
+        return any(Context.class);
+    }
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
@@ -6,16 +6,17 @@
 package org.mozilla.gecko.dlc;
 
 import android.content.Context;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 import org.robolectric.RuntimeEnvironment;
 
 import java.io.File;
 import java.util.Collections;
 
 import static org.mockito.Matchers.any;
 import static org.mockito.Matchers.anyString;
@@ -35,17 +36,17 @@ public class TestVerifyAction {
     /**
      * Scenario: Downloaded file does not exist anymore.
      *
      * Verify that:
      *  * Content is re-scheduled for download.
      */
     @Test
     public void testReschedulingIfFileDoesNotExist() throws Exception {
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
 
         File file = mock(File.class);
         when(file.exists()).thenReturn(false);
 
         VerifyAction action = spy(new VerifyAction());
         doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
@@ -75,17 +76,17 @@ public class TestVerifyAction {
     /**
      * Scenario: Checksum of existing file does not match expectation.
      *
      * Verify that:
      *  * Content is re-scheduled for download.
      */
     @Test
     public void testReschedulingIfVerificationFailed() throws Exception {
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
 
         File file = mock(File.class);
         when(file.exists()).thenReturn(true);
 
         VerifyAction action = spy(new VerifyAction());
         doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
@@ -100,17 +101,17 @@ public class TestVerifyAction {
      * Scenario: Downloaded file exists and has the correct checksum.
      *
      * Verify that:
      *  * No download is scheduled
      *  * Download action is not started
      */
     @Test
     public void testSuccessfulVerification() throws Exception {
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
         DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
         when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
 
         File file = mock(File.class);
         when(file.exists()).thenReturn(true);
 
         VerifyAction action = spy(new VerifyAction());
         doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
rename from mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContent.java
rename to mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContent.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
@@ -6,17 +6,17 @@ package org.mozilla.gecko.dlc.catalog;
 
 import org.json.JSONException;
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 
 @RunWith(TestRunner.class)
-public class TestDownloadContent {
+public class TestDownloadContentBuilder {
     /**
      * Verify that the values passed to the builder are all set on the DownloadContent object.
      */
     @Test
     public void testBuilder() {
         DownloadContent content = createTestContent();
 
         Assert.assertEquals("Some-ID", content.getId());
@@ -30,17 +30,17 @@ public class TestDownloadContent {
         Assert.assertEquals(27, content.getSize());
         Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
     }
 
     /**
      * Verify that a DownloadContent object exported to JSON and re-imported from JSON does not change.
      */
     public void testJSONSerializationAndDeserialization() throws JSONException {
-        DownloadContent content = DownloadContent.fromJSON(createTestContent().toJSON());
+        DownloadContent content = DownloadContentBuilder.fromJSON(DownloadContentBuilder.toJSON(createTestContent()));
 
         Assert.assertEquals("Some-ID", content.getId());
         Assert.assertEquals("/somewhere/something", content.getLocation());
         Assert.assertEquals("some.file", content.getFilename());
         Assert.assertEquals("Some-checksum", content.getChecksum());
         Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
         Assert.assertEquals(4223, content.getLastModified());
         Assert.assertEquals("Some-type", content.getType());
@@ -48,17 +48,17 @@ public class TestDownloadContent {
         Assert.assertEquals(27, content.getSize());
         Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
     }
 
     /**
      * Create a DownloadContent object with arbitrary data.
      */
     private DownloadContent createTestContent() {
-        return new DownloadContent.Builder()
+        return new DownloadContentBuilder()
                 .setId("Some-ID")
                 .setLocation("/somewhere/something")
                 .setFilename("some.file")
                 .setChecksum("Some-checksum")
                 .setDownloadChecksum("Some-download-checksum")
                 .setLastModified(4223)
                 .setType("Some-type")
                 .setKind("Some-kind")
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
@@ -1,28 +1,30 @@
 /* -*- 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.dlc.catalog;
 
+import android.support.v4.util.ArrayMap;
 import android.support.v4.util.AtomicFile;
 
 import org.junit.Assert;
 import org.junit.Assume;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Map;
 
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 
@@ -65,30 +67,30 @@ public class TestDownloadContentCatalog 
         Assume.assumeTrue("Fonts are excluded from build", AppConstants.MOZ_ANDROID_EXCLUDE_FONTS);
 
         AtomicFile file = mock(AtomicFile.class);
         doThrow(FileNotFoundException.class).when(file).readFully();
 
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
         catalog.loadFromDisk();
 
-        Assert.assertTrue("Catalog is not empty", catalog.getContentWithoutState().size() > 0);
+        Assert.assertTrue("Catalog is not empty", catalog.getContentToStudy().size() > 0);
     }
 
     /**
      * Scenario: Schedule downloading an item from the catalog.
      *
      * Verify that:
      *  * Catalog has changed
      */
     @Test
     public void testCatalogHasChangedWhenDownloadIsScheduled() throws Exception {
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
-        DownloadContent content = new DownloadContent.Builder().build();
-        catalog.onCatalogLoaded(Collections.singletonList(content));
+        DownloadContent content = new DownloadContentBuilder().build();
+        catalog.onCatalogLoaded(createMapOfContent(content));
 
         Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
 
         catalog.scheduleDownload(content);
 
         Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
     }
 
@@ -96,18 +98,18 @@ public class TestDownloadContentCatalog 
      * Scenario: Mark an item in the catalog as downloaded.
      *
      * Verify that:
      *  * Catalog has changed
      */
     @Test
     public void testCatalogHasChangedWhenContentIsDownloaded() throws Exception {
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
-        DownloadContent content = new DownloadContent.Builder().build();
-        catalog.onCatalogLoaded(Collections.singletonList(content));
+        DownloadContent content = new DownloadContentBuilder().build();
+        catalog.onCatalogLoaded(createMapOfContent(content));
 
         Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
 
         catalog.markAsDownloaded(content);
 
         Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
     }
 
@@ -115,18 +117,18 @@ public class TestDownloadContentCatalog 
      * Scenario: Mark an item in the catalog as permanently failed.
      *
      * Verify that:
      *  * Catalog has changed
      */
     @Test
     public void testCatalogHasChangedIfDownloadHasFailedPermanently() throws Exception {
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
-        DownloadContent content = new DownloadContent.Builder().build();
-        catalog.onCatalogLoaded(Collections.singletonList(content));
+        DownloadContent content = new DownloadContentBuilder().build();
+        catalog.onCatalogLoaded(createMapOfContent(content));
 
         Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
 
         catalog.markAsPermanentlyFailed(content);
 
         Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
     }
 
@@ -134,18 +136,18 @@ public class TestDownloadContentCatalog 
      * Scenario: Mark an item in the catalog as ignored.
      *
      * Verify that:
      *  * Catalog has changed
      */
     @Test
     public void testCatalogHasChangedIfContentIsIgnored() throws Exception {
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
-        DownloadContent content = new DownloadContent.Builder().build();
-        catalog.onCatalogLoaded(Collections.singletonList(content));
+        DownloadContent content = new DownloadContentBuilder().build();
+        catalog.onCatalogLoaded(createMapOfContent(content));
 
         Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
 
         catalog.markAsIgnored(content);
 
         Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
     }
 
@@ -157,18 +159,18 @@ public class TestDownloadContentCatalog 
      *  * After write: Catalog has not changed.
      */
     @Test
     public void testCatalogHasNotChangedAfterWritingToDisk() throws Exception {
         AtomicFile file = mock(AtomicFile.class);
         doReturn(mock(FileOutputStream.class)).when(file).startWrite();
 
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
-        DownloadContent content = new DownloadContent.Builder().build();
-        catalog.onCatalogLoaded(Collections.singletonList(content));
+        DownloadContent content = new DownloadContentBuilder().build();
+        catalog.onCatalogLoaded(createMapOfContent(content));
 
         catalog.scheduleDownload(content);
 
         Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
 
         catalog.writeToDisk();
 
         Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
@@ -180,53 +182,62 @@ public class TestDownloadContentCatalog 
      * Verify that:
      *  * getContentWithoutState(), getDownloadedContent() and getScheduledDownloads() returns
      *    the correct items depenending on their state.
      */
     @Test
     public void testContentClassification() {
         DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
 
-        DownloadContent content1 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
-        DownloadContent content2 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
-        DownloadContent content3 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
-        DownloadContent content4 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
-        DownloadContent content5 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
-        DownloadContent content6 = new DownloadContent.Builder().setState(DownloadContent.STATE_DOWNLOADED).build();
-        DownloadContent content7 = new DownloadContent.Builder().setState(DownloadContent.STATE_FAILED).build();
-        DownloadContent content8 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
-        DownloadContent content9 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
+        DownloadContent content1 = new DownloadContentBuilder().setId("A").setState(DownloadContent.STATE_NONE).build();
+        DownloadContent content2 = new DownloadContentBuilder().setId("B").setState(DownloadContent.STATE_NONE).build();
+        DownloadContent content3 = new DownloadContentBuilder().setId("C").setState(DownloadContent.STATE_SCHEDULED).build();
+        DownloadContent content4 = new DownloadContentBuilder().setId("D").setState(DownloadContent.STATE_SCHEDULED).build();
+        DownloadContent content5 = new DownloadContentBuilder().setId("E").setState(DownloadContent.STATE_SCHEDULED).build();
+        DownloadContent content6 = new DownloadContentBuilder().setId("F").setState(DownloadContent.STATE_DOWNLOADED).build();
+        DownloadContent content7 = new DownloadContentBuilder().setId("G").setState(DownloadContent.STATE_FAILED).build();
+        DownloadContent content8 = new DownloadContentBuilder().setId("H").setState(DownloadContent.STATE_IGNORED).build();
+        DownloadContent content9 = new DownloadContentBuilder().setId("I").setState(DownloadContent.STATE_IGNORED).build();
+        DownloadContent content10 = new DownloadContentBuilder().setId("J").setState(DownloadContent.STATE_UPDATED).build();
+        DownloadContent content11 = new DownloadContentBuilder().setId("K").setState(DownloadContent.STATE_DELETED).build();
+        DownloadContent content12 = new DownloadContentBuilder().setId("L").setState(DownloadContent.STATE_DELETED).build();
 
+        catalog.onCatalogLoaded(createMapOfContent(content1, content2, content3, content4, content5, content6,
+                content7, content8, content9, content10, content11, content12));
 
-        catalog.onCatalogLoaded(Arrays.asList(content1, content2, content3, content4, content5, content6,
-                                              content7, content8, content9));
+        Assert.assertTrue(catalog.hasScheduledDownloads());
 
-        Assert.assertEquals(2, catalog.getContentWithoutState().size());
+        Assert.assertEquals(3, catalog.getContentToStudy().size());
         Assert.assertEquals(1, catalog.getDownloadedContent().size());
         Assert.assertEquals(3, catalog.getScheduledDownloads().size());
+        Assert.assertEquals(2, catalog.getContentToDelete().size());
 
-        Assert.assertTrue(catalog.getContentWithoutState().contains(content1));
-        Assert.assertTrue(catalog.getContentWithoutState().contains(content2));
+        Assert.assertTrue(catalog.getContentToStudy().contains(content1));
+        Assert.assertTrue(catalog.getContentToStudy().contains(content2));
+        Assert.assertTrue(catalog.getContentToStudy().contains(content10));
 
         Assert.assertTrue(catalog.getDownloadedContent().contains(content6));
 
         Assert.assertTrue(catalog.getScheduledDownloads().contains(content3));
         Assert.assertTrue(catalog.getScheduledDownloads().contains(content4));
         Assert.assertTrue(catalog.getScheduledDownloads().contains(content5));
+
+        Assert.assertTrue(catalog.getContentToDelete().contains(content11));
+        Assert.assertTrue(catalog.getContentToDelete().contains(content12));
     }
 
     /**
      * Scenario: Calling rememberFailure() on a catalog with varying values
      */
     @Test
     public void testRememberingFailures() {
         DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
         Assert.assertFalse(catalog.hasCatalogChanged());
 
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
         Assert.assertEquals(0, content.getFailures());
 
         catalog.rememberFailure(content, 42);
         Assert.assertEquals(1, content.getFailures());
         Assert.assertTrue(catalog.hasCatalogChanged());
 
         catalog.rememberFailure(content, 42);
         Assert.assertEquals(2, content.getFailures());
@@ -245,23 +256,31 @@ public class TestDownloadContentCatalog 
      *
      * Verify that:
      *  * Content is marked as permanently failed
      */
     @Test
     public void testContentWillBeMarkedAsPermanentlyFailedAfterMultipleFailures() {
         DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
 
-        DownloadContent content = new DownloadContent.Builder().build();
+        DownloadContent content = new DownloadContentBuilder().build();
         Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
 
         for (int i = 0; i < 10; i++) {
             catalog.rememberFailure(content, 42);
 
             Assert.assertEquals(i + 1, content.getFailures());
             Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
         }
 
         catalog.rememberFailure(content, 42);
         Assert.assertEquals(10, content.getFailures());
         Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
     }
+
+    private ArrayMap<String, DownloadContent> createMapOfContent(DownloadContent... content) {
+        ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+        for (DownloadContent currentContent : content) {
+            map.put(currentContent.getId(), currentContent);
+        }
+        return map;
+    }
 }