Bug 1197720 - Introduce background service for downloadable content. r=rnewman
authorSebastian Kaspari <s.kaspari@gmail.com>
Wed, 02 Dec 2015 20:28:00 +0100
changeset 309648 b8249000cec9855cf25054abe8870a25bf0bbc3c
parent 309642 e09649f19381ab7c473827914b35f4196b88b060
child 309649 ca30d9070e53f75b90b9710d2533bb5bf0a74240
push id5513
push userraliiev@mozilla.com
push dateMon, 25 Jan 2016 13:55:34 +0000
treeherdermozilla-beta@5ee97dd05b5c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1197720, 1200291
milestone45.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1197720 - Introduce background service for downloadable content. r=rnewman This patch introduces a background service for downloading content from a catalog (bug 1200291). This catalog ships with the application and contains only fonts in this first version (MOZ_ANDROID_EXCLUDE_FONTS). For now this service is disabled by default (MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE).
configure.in
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/AppConstants.java.in
mobile/android/base/BrowserApp.java
mobile/android/base/GeckoApplication.java
mobile/android/base/dlc/DownloadContentHelper.java
mobile/android/base/dlc/DownloadContentService.java
mobile/android/base/dlc/catalog/DownloadContent.java
mobile/android/base/dlc/catalog/DownloadContentBootstrap.java
mobile/android/base/dlc/catalog/DownloadContentCatalog.java
mobile/android/base/moz.build
mobile/android/base/util/IOUtils.java
mobile/android/chrome/content/browser.js
--- a/configure.in
+++ b/configure.in
@@ -4861,16 +4861,23 @@ fi
 
 dnl =========================================================
 dnl = Whether to exclude hyphenations files in the build
 dnl =========================================================
 if test -n "$MOZ_EXCLUDE_HYPHENATION_DICTIONARIES"; then
     AC_DEFINE(MOZ_EXCLUDE_HYPHENATION_DICTIONARIES)
 fi
 
+dnl =========================================================
+dnl = Background service for downloading additional content at runtime.
+dnl =========================================================
+if test -n "$MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE"; then
+    AC_DEFINE(MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE)
+fi
+
 dnl ========================================================
 dnl = Include install tracking on Android
 dnl ========================================================
 if test -n "$MOZ_INSTALL_TRACKING"; then
     AC_DEFINE(MOZ_INSTALL_TRACKING)
 fi
 
 dnl ========================================================
@@ -8565,16 +8572,17 @@ AC_SUBST(MOZ_ANDROID_GECKOLIBS_AAR)
 AC_SUBST(MOZ_ANDROID_READING_LIST_SERVICE)
 AC_SUBST(MOZ_ANDROID_SEARCH_ACTIVITY)
 AC_SUBST(MOZ_ANDROID_TAB_QUEUE)
 AC_SUBST(MOZ_ANDROID_MLS_STUMBLER)
 AC_SUBST(MOZ_ANDROID_DOWNLOADS_INTEGRATION)
 AC_SUBST(MOZ_ANDROID_APPLICATION_CLASS)
 AC_SUBST(MOZ_ANDROID_BROWSER_INTENT_CLASS)
 AC_SUBST(MOZ_ANDROID_SEARCH_INTENT_CLASS)
+AC_SUBST(MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE)
 AC_SUBST(MOZ_EXCLUDE_HYPHENATION_DICTIONARIES)
 AC_SUBST(MOZ_INSTALL_TRACKING)
 AC_SUBST(MOZ_SWITCHBOARD)
 AC_SUBST(ENABLE_STRIP)
 AC_SUBST(PKG_SKIP_STRIP)
 AC_SUBST(STRIP_FLAGS)
 AC_SUBST(USE_ELF_HACK)
 AC_SUBST(INCREMENTAL_LINKER)
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -491,16 +491,21 @@
             android:process="@MANGLED_ANDROID_PACKAGE_NAME@.UpdateService">
         </service>
 
         <service
             android:exported="false"
             android:name="org.mozilla.gecko.NotificationService">
         </service>
 
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.dlc.DownloadContentService">
+        </service>
+
 
 #include ../services/manifests/FxAccountAndroidManifest_services.xml.in
 #include ../services/manifests/HealthReportAndroidManifest_services.xml.in
 #include ../services/manifests/SyncAndroidManifest_services.xml.in
 
         <service
             android:name="org.mozilla.gecko.tabqueue.TabReceivedService"
             android:exported="false" />
--- a/mobile/android/base/AppConstants.java.in
+++ b/mobile/android/base/AppConstants.java.in
@@ -345,9 +345,23 @@ public class AppConstants {
 //#else
     false;
 //#endif
 
     /**
      * Target CPU architecture: "armeabi-v7a", "x86, "mips", ..
      */
     public static final String ANDROID_CPU_ARCH = "@ANDROID_CPU_ARCH@";
+
+    public static final boolean MOZ_ANDROID_EXCLUDE_FONTS =
+//#ifdef MOZ_ANDROID_EXCLUDE_FONTS
+    true;
+//#else
+    false;
+//#endif
+
+    public static final boolean MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE =
+//#ifdef MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE
+    true;
+//#else
+    false;
+//#endif
 }
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -14,16 +14,17 @@ import org.mozilla.gecko.Tabs.TabEvents;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.TransitionsTracker;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 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.favicons.Favicons;
 import org.mozilla.gecko.favicons.LoadFaviconTask;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
 import org.mozilla.gecko.firstrun.FirstrunPane;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
 import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
 import org.mozilla.gecko.gfx.LayerView;
@@ -1802,16 +1803,20 @@ public class BrowserApp extends GeckoApp
                     ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
                         @Override
                         public void run() {
                              GeckoPreferences.broadcastStumblerPref(BrowserApp.this);
                         }
                     }, oneSecondInMillis);
                 }
 
+                if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+                    DownloadContentService.startVerification(this);
+                }
+
                 super.handleMessage(event, message);
             } else if (event.equals("Gecko:Ready")) {
                 // Handle this message in GeckoApp, but also enable the Settings
                 // menuitem, which is specific to BrowserApp.
                 super.handleMessage(event, message);
                 final Menu menu = mMenu;
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
--- a/mobile/android/base/GeckoApplication.java
+++ b/mobile/android/base/GeckoApplication.java
@@ -1,17 +1,20 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
+import org.mozilla.gecko.AdjustConstants;
+import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.dlc.DownloadContentService;
 import org.mozilla.gecko.home.HomePanelsManager;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.mdns.MulticastDNSManager;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.Application;
@@ -150,16 +153,20 @@ public class GeckoApplication extends Ap
                 // its own thing.
                 return new LocalBrowserDB(profileName);
             }
         });
 
         GeckoService.register();
 
         super.onCreate();
+
+        if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+            DownloadContentService.startStudy(this);
+        }
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
 
     public LightweightTheme getLightweightTheme() {
         return mLightweightTheme;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/DownloadContentHelper.java
@@ -0,0 +1,288 @@
+/* -*- 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 org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.IOUtils;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.support.v4.net.ConnectivityManagerCompat;
+import android.util.Log;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+import ch.boye.httpclientandroidlib.client.HttpClient;
+import ch.boye.httpclientandroidlib.client.methods.HttpGet;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpRequestRetryHandler;
+import ch.boye.httpclientandroidlib.impl.client.HttpClientBuilder;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.NoSuchAlgorithmException;
+import java.util.zip.GZIPInputStream;
+
+/* package-private */ class DownloadContentHelper {
+    private static final String LOGTAG = "GeckoDLCHelper";
+
+    public static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
+    public static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
+    public static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
+
+    private static final String CDN_BASE_URL = "https://mobile.cdn.mozilla.net/";
+
+    private static final String CACHE_DIRECTORY = "downloadContent";
+
+    /**
+     * Exception indicating a recoverable error has happened. Download of the content will be retried later.
+     */
+    /* package-private */ static class RecoverableDownloadContentException extends Exception {
+        private static final long serialVersionUID = -2246772819507370734L;
+
+        public RecoverableDownloadContentException(String message) {
+            super(message);
+        }
+
+        public RecoverableDownloadContentException(Throwable cause) {
+            super(cause);
+        }
+    }
+
+    /**
+     * If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try
+     * downloading it again - until a newer version of the content is available.
+     */
+    /* package-private */ static class UnrecoverableDownloadContentException extends Exception {
+        private static final long serialVersionUID = 8956080754787367105L;
+
+        public UnrecoverableDownloadContentException(String message) {
+            super(message);
+        }
+
+        public UnrecoverableDownloadContentException(Throwable cause) {
+            super(cause);
+        }
+    }
+
+    /* package-private */ static HttpClient buildHttpClient() {
+        // TODO: Implement proxy support (Bug 1209496)
+        return HttpClientBuilder.create()
+            .setUserAgent(HardwareUtils.isTablet() ?
+                    AppConstants.USER_AGENT_FENNEC_TABLET :
+                    AppConstants.USER_AGENT_FENNEC_MOBILE)
+            .setRetryHandler(new DefaultHttpRequestRetryHandler())
+            .build();
+    }
+
+    /* package-private */ static File createTemporaryFile(Context context, DownloadContent content)
+            throws RecoverableDownloadContentException {
+        File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
+
+        if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
+            // Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways.
+            throw new RecoverableDownloadContentException("Could not create cache directory: " + cacheDirectory);
+        }
+
+        return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId());
+    }
+
+    /* package-private */ static void download(HttpClient client, String source, File temporaryFile)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+
+        final HttpGet request = new HttpGet(source);
+
+        try {
+            final HttpResponse response = client.execute(request);
+            final int status = response.getStatusLine().getStatusCode();
+            if (status != HttpStatus.SC_OK) {
+                // 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("(Recoverable) Download failed. Status code: " + status);
+                } else if (status >= 400) {
+                    // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
+                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
+                } else {
+                    // 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);
+                }
+            }
+
+            final HttpEntity entity = response.getEntity();
+            if (entity == null) {
+                // Recoverable: Should not happen for a valid asset
+                throw new RecoverableDownloadContentException("Null entity");
+            }
+
+            inputStream = new BufferedInputStream(entity.getContent());
+            outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile));
+
+            IOUtils.copy(inputStream, outputStream);
+
+            inputStream.close();
+            outputStream.close();
+        } catch (IOException e) {
+            // TODO: Support resuming downloads using 'Range' header (Bug 1209513)
+            temporaryFile.delete();
+
+            // Recoverable: Just I/O discontinuation
+            throw new RecoverableDownloadContentException(e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+            IOUtils.safeStreamClose(outputStream);
+        }
+    }
+
+    /* package-private */ static boolean verify(File file, String expectedChecksum)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        InputStream inputStream = null;
+
+        try {
+            inputStream = new BufferedInputStream(new FileInputStream(file));
+
+            byte[] ctx = NativeCrypto.sha256init();
+            if (ctx == null) {
+                throw new RecoverableDownloadContentException("Could not create SHA-256 context");
+            }
+
+            byte[] buffer = new byte[4096];
+            int read;
+
+            while ((read = inputStream.read(buffer)) != -1) {
+                NativeCrypto.sha256update(ctx, buffer, read);
+            }
+
+            String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
+
+            if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) {
+                Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum);
+                return false;
+            }
+
+            return true;
+        } catch (IOException e) {
+            // Recoverable: Just I/O discontinuation
+            throw new RecoverableDownloadContentException(e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+        }
+    }
+
+    /* package-private */ static void move(File temporaryFile, File destinationFile)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        if (!temporaryFile.renameTo(destinationFile)) {
+            Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy..");
+            copy(temporaryFile, destinationFile);
+            temporaryFile.delete();
+        }
+    }
+
+    /* package-private */ static void extract(File sourceFile, File destinationFile, String checksum)
+            throws UnrecoverableDownloadContentException, RecoverableDownloadContentException {
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+        File temporaryFile = null;
+
+        try {
+            File destinationDirectory = destinationFile.getParentFile();
+            if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+                throw new IOException("Destination directory does not exist and cannot be created");
+            }
+
+            temporaryFile = new File(destinationDirectory, destinationFile.getName() + ".tmp");
+
+            inputStream = new GZIPInputStream(new BufferedInputStream(new FileInputStream(sourceFile)));
+            outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile));
+
+            IOUtils.copy(inputStream, outputStream);
+
+            inputStream.close();
+            outputStream.close();
+
+            if (!verify(temporaryFile, checksum)) {
+                Log.w(LOGTAG, "Checksum of extracted file does not match.");
+                return;
+            }
+
+            move(temporaryFile, destinationFile);
+        } catch (IOException e) {
+            // We do not support resume yet (Bug 1209513). Therefore we have to treat this as unrecoverable: The
+            // temporarily file will be deleted and we want to avoid downloading and failing repeatedly.
+            throw new UnrecoverableDownloadContentException(e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+            IOUtils.safeStreamClose(outputStream);
+
+            if (temporaryFile != null && temporaryFile.exists()) {
+                temporaryFile.delete();
+            }
+        }
+    }
+
+    private static void copy(File temporaryFile, File destinationFile)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+
+        try {
+            File destinationDirectory = destinationFile.getParentFile();
+            if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+                throw new IOException("Destination directory does not exist and cannot be created");
+            }
+
+            inputStream = new BufferedInputStream(new FileInputStream(temporaryFile));
+            outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile));
+
+            IOUtils.copy(inputStream, outputStream);
+
+            inputStream.close();
+            outputStream.close();
+        } catch (IOException e) {
+            // Meh. This is an awkward situation: We downloaded the content but we can't move it to its destination. We
+            // are treating this as "unrecoverable" error because we want to avoid downloading this again and again and
+            // then always failing to copy it to the destination. This will be fixed after we implement resuming
+            // downloads (Bug 1209513): We will keep the downloaded temporary file and just retry copying.
+            throw new UnrecoverableDownloadContentException(e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+            IOUtils.safeStreamClose(outputStream);
+        }
+    }
+
+    /* package-private */ static File getDestinationFile(Context context, DownloadContent content)  throws UnrecoverableDownloadContentException {
+        if (content.isFont()) {
+            return new File(new File(context.getApplicationInfo().dataDir, "fonts"), content.getFilename());
+        }
+
+        // Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen)
+        throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind());
+    }
+
+    /* package-private */ static boolean isActiveNetworkMetered(Context context) {
+        return ConnectivityManagerCompat.isActiveNetworkMetered(
+                (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
+    }
+
+    /* package-private */ static String createDownloadURL(DownloadContent content) {
+        return CDN_BASE_URL + content.getLocation();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/DownloadContentService.java
@@ -0,0 +1,224 @@
+/* -*- 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 org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.dlc.DownloadContentHelper;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+
+import android.app.IntentService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import ch.boye.httpclientandroidlib.client.HttpClient;
+
+import java.io.File;
+
+/**
+ * Service to handle downloadable content that did not ship with the APK.
+ */
+public class DownloadContentService extends IntentService {
+    private static final String LOGTAG = "GeckoDLCService";
+
+    public static void startStudy(Context context) {
+        Intent intent = new Intent(DownloadContentHelper.ACTION_STUDY_CATALOG);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
+
+    public static void startVerification(Context context) {
+        Intent intent = new Intent(DownloadContentHelper.ACTION_VERIFY_CONTENT);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
+
+    public static void startDownloads(Context context) {
+        Intent intent = new Intent(DownloadContentHelper.ACTION_DOWNLOAD_CONTENT);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
+
+    private DownloadContentCatalog catalog;
+
+    public DownloadContentService() {
+        super(LOGTAG);
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        catalog = new DownloadContentCatalog(this);
+    }
+
+    protected void onHandleIntent(Intent intent) {
+        if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+            Log.w(LOGTAG, "Download content is not enabled. Stop.");
+            return;
+        }
+
+        if (intent == null) {
+            return;
+        }
+
+        switch (intent.getAction()) {
+            case DownloadContentHelper.ACTION_STUDY_CATALOG:
+                studyCatalog();
+                break;
+
+            case DownloadContentHelper.ACTION_DOWNLOAD_CONTENT:
+                downloadContent();
+                break;
+
+            case DownloadContentHelper.ACTION_VERIFY_CONTENT:
+                verifyCatalog();
+                break;
+
+            default:
+                Log.e(LOGTAG, "Unknown action: " + intent.getAction());
+        }
+
+        catalog.persistChanges();
+    }
+
+    /**
+     * Study: Scan the catalog for "new" content available for download.
+     */
+    private void studyCatalog() {
+        Log.d(LOGTAG, "Studying catalog..");
+
+        for (DownloadContent content : catalog.getContentWithoutState()) {
+            if (content.isAssetArchive() && content.isFont()) {
+                catalog.scheduleDownload(content);
+
+                Log.d(LOGTAG, "Scheduled download: " + content);
+            }
+        }
+
+        if (catalog.hasScheduledDownloads()) {
+            startDownloads(this);
+        }
+
+        Log.v(LOGTAG, "Done");
+    }
+
+    /**
+     * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+     */
+    private void verifyCatalog() {
+        Log.d(LOGTAG, "Verifying catalog..");
+
+        for (DownloadContent content : catalog.getDownloadedContent()) {
+            try {
+                File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
+
+                if (!destinationFile.exists()) {
+                    Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content);
+
+                    // This file does not exist even though it is marked as downloaded in the catalog. Scheduling a
+                    // download to fetch it again.
+                    catalog.scheduleDownload(content);
+                }
+
+                if (!DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
+                    catalog.scheduleDownload(content);
+                    Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
+                    continue;
+                }
+
+                Log.v(LOGTAG, "Content okay: " + content);
+            } catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
+                Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e);
+            } catch (DownloadContentHelper.RecoverableDownloadContentException e) {
+                // That's okay, we are just verifying already existing content. No log.
+            }
+        }
+
+        if (catalog.hasScheduledDownloads()) {
+            startDownloads(this);
+        }
+
+        Log.v(LOGTAG, "Done");
+    }
+
+    /**
+     * Download content that has been scheduled during "study" or "verify".
+     */
+    private void downloadContent() {
+        Log.d(LOGTAG, "Downloading content..");
+
+        if (DownloadContentHelper.isActiveNetworkMetered(this)) {
+            Log.d(LOGTAG, "Network is metered. Postponing download.");
+            // TODO: Reschedule download (bug 1209498)
+            return;
+        }
+
+        HttpClient client = DownloadContentHelper.buildHttpClient();
+
+        for (DownloadContent content : catalog.getScheduledDownloads()) {
+            Log.d(LOGTAG, "Downloading: " + content);
+
+            File temporaryFile = null;
+
+            try {
+                File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
+                if (destinationFile.exists() && DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
+                    Log.d(LOGTAG, "Content already exists and is up-to-date.");
+                    continue;
+                }
+
+                temporaryFile = DownloadContentHelper.createTemporaryFile(this, content);
+
+                // TODO: Check space on disk before downloading content (bug 1220145)
+                final String url = DownloadContentHelper.createDownloadURL(content);
+                DownloadContentHelper.download(client, url, temporaryFile);
+
+                if (!DownloadContentHelper.verify(temporaryFile, content.getDownloadChecksum())) {
+                    Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
+                    temporaryFile.delete();
+                    continue;
+                }
+
+                if (!content.isAssetArchive()) {
+                    Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType());
+                    continue;
+                }
+
+                DownloadContentHelper.extract(temporaryFile, destinationFile, content.getChecksum());
+
+                catalog.markAsDownloaded(content);
+
+                Log.d(LOGTAG, "Successfully downloaded: " + content);
+
+                onContentDownloaded(content);
+            } catch (DownloadContentHelper.RecoverableDownloadContentException e) {
+                Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e);
+                // TODO: Reschedule download (bug 1209498)
+            } catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
+                Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
+
+                catalog.markAsPermanentlyFailed(content);
+            } finally {
+                if (temporaryFile != null && temporaryFile.exists()) {
+                    temporaryFile.delete();
+                }
+            }
+        }
+
+        Log.v(LOGTAG, "Done");
+    }
+
+    private void onContentDownloaded(DownloadContent content) {
+        if (content.isFont()) {
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Fonts:Reload", ""));
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/catalog/DownloadContent.java
@@ -0,0 +1,223 @@
+/* -*- 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.annotation.IntDef;
+import android.support.annotation.NonNull;
+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";
+
+    @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED})
+    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;
+
+    @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";
+
+    private final String id;
+    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 int state;
+
+    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) {
+        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;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    /* package-private */ void setState(@State int state) {
+        this.state = state;
+    }
+
+    @State
+    public int getState() {
+        return state;
+    }
+
+    @Kind
+    public String getKind() {
+        return kind;
+    }
+
+    @Type
+    public String getType() {
+        return type;
+    }
+
+    public String getLocation() {
+        return location;
+    }
+
+    public String getFilename() {
+        return filename;
+    }
+
+    public String getChecksum() {
+        return checksum;
+    }
+
+    public String getDownloadChecksum() {
+        return downloadChecksum;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    public boolean isFont() {
+        return KIND_FONT.equals(kind);
+    }
+
+    public boolean isAssetArchive() {
+        return TYPE_ASSET_ARCHIVE.equals(type);
+    }
+
+    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))
+                .build();
+    }
+
+    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);
+        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;
+
+        public DownloadContent build() {
+            DownloadContent content = new DownloadContent(id, location, filename, checksum, downloadChecksum,
+                                                          lastModified, type, kind, size);
+            content.setState(state);
+            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;
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/catalog/DownloadContentBootstrap.java
@@ -0,0 +1,156 @@
+/* -*- 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 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() {
+        if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) {
+            // We are packaging fonts. There's nothing we want to download;
+            return Collections.emptyList();
+        }
+
+        return Arrays.asList(
+                new DownloadContent.Builder()
+                        .setId("bff50e08-7bbc-4d77-a907-bb0a54434bee")
+                        .setLocation("fonts/CharisSILCompact-B.ttf.gz")
+                        .setFilename("CharisSILCompact-B.ttf")
+                        .setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d")
+                        .setDownloadChecksum("ff7ecae7669a51d5fa6a5f8e703278ebda3a68f51bc49c4321bde4438020d639")
+                        .setSize(1676072)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("68c6472d-94a6-4fb2-8525-78e427b022fe")
+                        .setLocation("fonts/CharisSILCompact-BI.ttf.gz")
+                        .setFilename("CharisSILCompact-BI.ttf")
+                        .setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b")
+                        .setDownloadChecksum("dfb6d583edd27d5e6d91d479e6c8a5706275662c940c65b70911493bb279904a")
+                        .setSize(1667812)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("33d0ce0d-9c48-4a37-8b74-81cce872061b")
+                        .setLocation("fonts/CharisSILCompact-I.ttf.gz")
+                        .setFilename("CharisSILCompact-I.ttf")
+                        .setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767")
+                        .setDownloadChecksum("5a257ec3c5226e7be0be65e463f5b22eff108da853b9ff7bc47f1733b1ddacf2")
+                        .setSize(1693988)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("7e274cdc-4216-4dc0-b7a5-8ec333f0c287")
+                        .setLocation("fonts/CharisSILCompact-R.ttf.gz")
+                        .setFilename("CharisSILCompact-R.ttf")
+                        .setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067")
+                        .setDownloadChecksum("cab284228b8dfe8ef46c3f1af70b5b6f9e92878f05e741ecc611e5e750a4a3b3")
+                        .setSize(1727656)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("b144002f-d5de-448c-8952-da1e405e022f")
+                        .setLocation("fonts/ClearSans-Bold.ttf.gz")
+                        .setFilename("ClearSans-Bold.ttf")
+                        .setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70")
+                        .setDownloadChecksum("d95168996dc932e6504cb5448fcb759e0ee6e66c5c8603293b046d28ab589cce")
+                        .setSize(140136)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("f07502f5-e4c5-41a8-8788-89717397a98d")
+                        .setLocation("fonts/ClearSans-BoldItalic.ttf.gz")
+                        .setFilename("ClearSans-BoldItalic.ttf")
+                        .setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198")
+                        .setDownloadChecksum("f5e18f4acc4ceaeca9e081b1be79cd6034e0dc7ad683fa240195fd6c838452e0")
+                        .setSize(156124)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("afafc7ef-f516-42da-88d4-d8435f65541b")
+                        .setLocation("fonts/ClearSans-Italic.ttf.gz")
+                        .setFilename("ClearSans-Italic.ttf")
+                        .setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68")
+                        .setDownloadChecksum("56d12114ac15d913d7d9876c698889cd25f26e14966a8bd7424aeb0f61ffaf87")
+                        .setSize(155672)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("28521d9b-ac2e-45d0-89b6-a4c9076dbf6d")
+                        .setLocation("fonts/ClearSans-Light.ttf.gz")
+                        .setFilename("ClearSans-Light.ttf")
+                        .setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a")
+                        .setDownloadChecksum("1fc716662866b9c01e32dda3fc9c54ca3e57de8c6ac523f46305d8ae6c0a9cf4")
+                        .setSize(145976)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("13f01bf4-da71-4673-9c60-ec0e9a45c38c")
+                        .setLocation("fonts/ClearSans-Medium.ttf.gz")
+                        .setFilename("ClearSans-Medium.ttf")
+                        .setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1")
+                        .setDownloadChecksum("a29184ec6621dbd3bc6ae1e30bba70c479d1001bca647ea4a205ecb64d5a00a0")
+                        .setSize(148892)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("73104370-c7ee-4b5b-bb37-392a4e66f65a")
+                        .setLocation("fonts/ClearSans-MediumItalic.ttf.gz")
+                        .setFilename("ClearSans-MediumItalic.ttf")
+                        .setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7")
+                        .setDownloadChecksum("a381a3d4060e993af440a7b72fed29fa3a488536cc451d7c435d5fae1256318b")
+                        .setSize(155228)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("274f3718-f6e0-40b4-b52a-44812ec3ea9e")
+                        .setLocation("fonts/ClearSans-Regular.ttf.gz")
+                        .setFilename("ClearSans-Regular.ttf")
+                        .setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b")
+                        .setDownloadChecksum("87dec7f0331e19b293fc510f2764b9bd1b94595ac279cf9414f8d03c5bf34dca")
+                        .setSize(142572)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("77803858-3cfb-4a0d-a1d3-fa1bf8a6c604")
+                        .setLocation("fonts/ClearSans-Thin.ttf.gz")
+                        .setFilename("ClearSans-Thin.ttf")
+                        .setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a")
+                        .setDownloadChecksum("64300b48b2867e5642212690f0ff9ea3988f47790311c444a81d25213b4102aa")
+                        .setSize(147004)
+                        .setKind("font")
+                        .setType("asset-archive")
+                        .build()
+        );
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/catalog/DownloadContentCatalog.java
@@ -0,0 +1,228 @@
+/* -*- 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 org.mozilla.gecko.dlc.catalog.DownloadContentBootstrap;
+
+import android.content.Context;
+import android.support.v4.util.AtomicFile;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+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 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'
+
+    public DownloadContentCatalog(Context context) {
+        content = Collections.emptyList();
+        file = new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME));
+
+        startLoadFromDisk();
+    }
+
+    public synchronized List<DownloadContent> getContentWithoutState() {
+        awaitLoadingCatalogLocked();
+
+        List<DownloadContent> contentWithoutState = new ArrayList<>();
+
+        for (DownloadContent content : this.content) {
+            if (DownloadContent.STATE_NONE == content.getState()) {
+                contentWithoutState.add(content);
+            }
+        }
+
+        return contentWithoutState;
+    }
+
+    public synchronized List<DownloadContent> getDownloadedContent() {
+        awaitLoadingCatalogLocked();
+
+        List<DownloadContent> downloadedContent = new ArrayList<>();
+        for (DownloadContent content : this.content) {
+            if (DownloadContent.STATE_DOWNLOADED == content.getState()) {
+                downloadedContent.add(content);
+            }
+        }
+
+        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;
+    }
+
+    public synchronized void scheduleDownload(DownloadContent content) {
+        content.setState(DownloadContent.STATE_SCHEDULED);
+        hasCatalogChanged = true;
+    }
+
+    public synchronized void markAsDownloaded(DownloadContent content) {
+        content.setState(DownloadContent.STATE_DOWNLOADED);
+        hasCatalogChanged = true;
+    }
+
+    public synchronized void markAsPermanentlyFailed(DownloadContent content) {
+        content.setState(DownloadContent.STATE_FAILED);
+        hasCatalogChanged = true;
+    }
+
+    public synchronized void markAsIgnored(DownloadContent content) {
+        content.setState(DownloadContent.STATE_IGNORED);
+        hasCatalogChanged = true;
+    }
+
+    public void persistChanges() {
+        new Thread(LOGTAG + "-Persist") {
+            public void run() {
+                writeToDisk();
+            }
+        }.start();
+    }
+
+    private void startLoadFromDisk() {
+        new Thread(LOGTAG + "-Load") {
+            public void run() {
+                loadFromDisk();
+            }
+        }.start();
+    }
+
+    private void awaitLoadingCatalogLocked() {
+        while (!hasLoadedCatalog) {
+            try {
+                Log.v(LOGTAG, "Waiting for catalog to be loaded");
+
+                wait();
+            } catch (InterruptedException e) {
+                // Ignore
+            }
+        }
+    }
+
+    private synchronized void loadFromDisk() {
+        Log.d(LOGTAG, "Loading from disk");
+
+        if (hasLoadedCatalog) {
+            return;
+        }
+
+        List<DownloadContent> content = new ArrayList<DownloadContent>();
+
+        try {
+            JSONArray array;
+
+            synchronized (file) {
+                array = new JSONArray(new String(file.readFully(), "UTF-8"));
+            }
+
+            for (int i = 0; i < array.length(); i++) {
+                content.add(DownloadContent.fromJSON(array.getJSONObject(i)));
+            }
+        } catch (FileNotFoundException e) {
+            Log.d(LOGTAG, "Catalog file does not exist: Bootstrapping initial catalog");
+            content = 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();
+            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);
+        }
+
+        this.content = content;
+        this.hasLoadedCatalog = true;
+
+        notifyAll();
+
+        Log.d(LOGTAG, "Loaded " + content.size() + " elements");
+    }
+
+    private synchronized void writeToDisk() {
+        if (!hasCatalogChanged) {
+            Log.v(LOGTAG, "Not persisting: Catalog has not changed");
+            return;
+        }
+
+        Log.d(LOGTAG, "Writing to disk");
+
+        FileOutputStream outputStream = null;
+
+        synchronized (file) {
+            try {
+                outputStream = file.startWrite();
+
+                JSONArray array = new JSONArray();
+                for (DownloadContent content : this.content) {
+                    array.put(content.toJSON());
+                }
+
+                outputStream.write(array.toString().getBytes("UTF-8"));
+
+                file.finishWrite(outputStream);
+
+                hasCatalogChanged = false;
+            } catch (UnsupportedEncodingException e) {
+                AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
+                error.initCause(e);
+                throw error;
+            } catch (IOException | JSONException e) {
+                Log.e(LOGTAG, "IOException during writing catalog", e);
+
+                if (outputStream != null) {
+                    file.failWrite(outputStream);
+                }
+            }
+        }
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -242,16 +242,21 @@ gbjar.sources += [
     'db/TabsProvider.java',
     'db/TopSitesCursorWrapper.java',
     'db/URLMetadata.java',
     'db/URLMetadataTable.java',
     'DevToolsAuthHelper.java',
     'distribution/Distribution.java',
     'distribution/ReferrerDescriptor.java',
     'distribution/ReferrerReceiver.java',
+    'dlc/catalog/DownloadContent.java',
+    'dlc/catalog/DownloadContentBootstrap.java',
+    'dlc/catalog/DownloadContentCatalog.java',
+    'dlc/DownloadContentHelper.java',
+    'dlc/DownloadContentService.java',
     'DoorHangerPopup.java',
     'DownloadsIntegration.java',
     'DynamicToolbar.java',
     'EditBookmarkDialog.java',
     'EventDispatcher.java',
     'favicons/cache/FaviconCache.java',
     'favicons/cache/FaviconCacheElement.java',
     'favicons/cache/FaviconsForURL.java',
--- a/mobile/android/base/util/IOUtils.java
+++ b/mobile/android/base/util/IOUtils.java
@@ -5,16 +5,17 @@
 
 package org.mozilla.gecko.util;
 
 import android.util.Log;
 
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 
 /**
  * Static helper class containing useful methods for manipulating IO objects.
  */
 public class IOUtils {
     private static final String LOGTAG = "GeckoIOUtils";
 
     /**
@@ -111,9 +112,18 @@ public class IOUtils {
     }
 
     public static void safeStreamClose(Closeable stream) {
         try {
             if (stream != null)
                 stream.close();
         } catch (IOException e) {}
     }
+
+    public static void copy(InputStream in, OutputStream out) throws IOException {
+        byte[] buffer = new byte[4096];
+        int len;
+
+        while ((len = in.read(buffer)) != -1) {
+            out.write(buffer, 0, len);
+        }
+    }
 }
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -115,16 +115,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 XPCOMUtils.defineLazyModuleGetter(this, "Notifications",
                                   "resource://gre/modules/Notifications.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");
 
+XPCOMUtils.defineLazyServiceGetter(this, "FontEnumerator",
+  "@mozilla.org/gfx/fontenumerator;1",
+  "nsIFontEnumerator");
+
 var lazilyLoadedBrowserScripts = [
   ["SelectHelper", "chrome://browser/content/SelectHelper.js"],
   ["InputWidgetHelper", "chrome://browser/content/InputWidgetHelper.js"],
   ["MasterPassword", "chrome://browser/content/MasterPassword.js"],
   ["PluginHelper", "chrome://browser/content/PluginHelper.js"],
   ["OfflineApps", "chrome://browser/content/OfflineApps.js"],
   ["Linkifier", "chrome://browser/content/Linkify.js"],
   ["ZoomHelper", "chrome://browser/content/ZoomHelper.js"],
@@ -451,16 +455,18 @@ var BrowserApp = {
     Services.obs.addObserver(this, "webapps-ask-install", false);
     Services.obs.addObserver(this, "webapps-ask-uninstall", false);
     Services.obs.addObserver(this, "webapps-launch", false);
     Services.obs.addObserver(this, "webapps-runtime-uninstall", false);
     Services.obs.addObserver(this, "Webapps:AutoInstall", false);
     Services.obs.addObserver(this, "Webapps:Load", false);
     Services.obs.addObserver(this, "Webapps:AutoUninstall", false);
     Services.obs.addObserver(this, "sessionstore-state-purge-complete", false);
+    Services.obs.addObserver(this, "Fonts:Reload", false);
+
     Messaging.addListener(this.getHistory.bind(this), "Session:GetHistory");
 
     function showFullScreenWarning() {
       Snackbars.show(Strings.browser.GetStringFromName("alertFullScreenToast"), Snackbars.LENGTH_SHORT);
     }
 
     window.addEventListener("fullscreen", function() {
       Messaging.sendRequest({
@@ -2095,16 +2101,20 @@ var BrowserApp = {
 
       case "Tiles:Click":
         // Set the click data for the given tab to be handled on the next page load.
         let data = JSON.parse(aData);
         let tab = this.getTabForId(data.tabId);
         tab.tilesData = data.payload;
         break;
 
+      case "Fonts:Reload":
+        FontEnumerator.updateFontList();
+        break;
+
       default:
         dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n');
         break;
 
     }
   },
 
   /**
@@ -7635,8 +7645,9 @@ HTMLContextMenuItem.prototype = Object.c
         icon: elt.icon,
         label: elt.label,
         disabled: elt.disabled,
         menu: elt instanceof Ci.nsIDOMHTMLMenuElement
       };
     }
   },
 });
+