Bug 1200291 - Implement simple package registry for downloadable content. draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Wed, 02 Sep 2015 10:01:31 +0200
changeset 297234 75e442e441e504aaeef681ff03eb8d6a918e7e52
parent 297233 07e223c66f1a2eb16c2e7d19b49f4bc407872819
child 297235 3b1ff72be166cd5b287e72f937dfe62a6e7ed5b6
push id5932
push users.kaspari@gmail.com
push dateTue, 29 Sep 2015 17:50:37 +0000
bugs1200291
milestone44.0a1
Bug 1200291 - Implement simple package registry for downloadable content. This patch introduces a local representation of a catalog of downloadable content. DownloadContentService now handles multiple actions. For now these are: * Verify: Going through the catalog and looking for installable content. Verifying that downloaded content is still in place and up to date (checksum). * Download: Downloading content that has been scheduled for download during the "verify" step. Later this service would handle additional actions to update the catalog from a server component. For now the catalog is static and bootstraped once on first launch (DownloadContentBootstrap). This version handles some but not all edge cases (see TODO markers).
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/DownloadContent.java
mobile/android/base/dlc/DownloadContentBootstrap.java
mobile/android/base/dlc/DownloadContentHelper.java
mobile/android/base/dlc/DownloadContentReceiver.java
mobile/android/base/dlc/DownloadContentService.java
mobile/android/base/dlc/FontsHelper.java
mobile/android/base/dlc/LocalCatalogStorage.java
mobile/android/base/moz.build
mobile/android/base/util/IOUtils.java
mobile/android/chrome/content/browser.js
--- a/configure.in
+++ b/configure.in
@@ -3946,16 +3946,28 @@ MOZ_ARG_DISABLE_BOOL(android-include-fon
                           Disable the inclusion of fonts into the final APK],
     MOZ_ANDROID_EXCLUDE_FONTS=1)
 
 if test -n "$MOZ_ANDROID_EXCLUDE_FONTS"; then
     AC_DEFINE(MOZ_ANDROID_EXCLUDE_FONTS)
 fi
 AC_SUBST(MOZ_ANDROID_EXCLUDE_FONTS)
 
+# Whether to download additional content (fonts, hyphenation dictionaries, ..) at runtime.
+# We want this in mobile/android/confvars.sh, so it goes early.
+MOZ_ARG_DISABLE_BOOL(android-download-content,
+[  --disable-android-download-content
+                          Disable downloading additional content (fonts, hyphenation dictionaries, ..) at runtime],
+    MOZ_ANDROID_DOWNLOAD_CONTENT=1)
+
+if test -n "$MOZ_ANDROID_DOWNLOAD_CONTENT"; then
+    AC_DEFINE(MOZ_ANDROID_DOWNLOAD_CONTENT)
+fi
+AC_SUBST(MOZ_ANDROID_DOWNLOAD_CONTENT)
+
 # Whether this APK is destined for resource constrained devices.
 # We want this in mobile/android/confvars.sh, so it goes early.
 MOZ_ARG_ENABLE_BOOL(android-resource-constrained,
 [  --enable-android-resource-constrained
                           Exclude hi-res images and similar from the final APK],
     MOZ_ANDROID_RESOURCE_CONSTRAINED=1)
 
 if test -n "$MOZ_ANDROID_RESOURCE_CONSTRAINED"; then
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -489,22 +489,16 @@
             android:name="org.mozilla.gecko.NotificationService">
         </service>
 
         <service
             android:exported="false"
             android:name="org.mozilla.gecko.dlc.DownloadContentService">
         </service>
 
-        <receiver android:name="org.mozilla.gecko.dlc.DownloadContentReceiver">
-            <intent-filter>
-                <action android:name="@ANDROID_PACKAGE_NAME@.DOWNLOAD_CONTENT_AVAILABLE" />
-            </intent-filter>
-        </receiver>
-
 
 #include ../services/manifests/FxAccountAndroidManifest_services.xml.in
 #include ../services/manifests/HealthReportAndroidManifest_services.xml.in
 #include ../services/manifests/SyncAndroidManifest_services.xml.in
 #ifdef MOZ_ANDROID_SEARCH_ACTIVITY
 #include ../search/manifests/SearchAndroidManifest_services.xml.in
 #endif
 #ifdef MOZ_ANDROID_MLS_STUMBLER
--- a/mobile/android/base/AppConstants.java.in
+++ b/mobile/android/base/AppConstants.java.in
@@ -339,9 +339,16 @@ public class AppConstants {
 //#endif
 
     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 =
+//#ifdef MOZ_ANDROID_DOWNLOAD_CONTENT
+    true;
+//#else
+    false;
+//#endif
 }
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -14,18 +14,16 @@ import org.mozilla.gecko.GeckoProfileDir
 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.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.dlc.FontsHelper;
 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;
@@ -1953,18 +1951,16 @@ public class BrowserApp extends GeckoApp
                     ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
                         @Override
                         public void run() {
                              GeckoPreferences.broadcastStumblerPref(BrowserApp.this);
                         }
                     }, oneSecondInMillis);
                 }
 
-                FontsHelper.scheduleFontsDownloadIfNeeded(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;
@@ -148,16 +151,24 @@ public class GeckoApplication extends Ap
                 // Note that we don't use the profile directory -- we
                 // send operations to the ContentProvider, which does
                 // its own thing.
                 return new LocalBrowserDB(profileName);
             }
         });
 
         super.onCreate();
+
+        if (AppConstants.MOZ_INSTALL_TRACKING) {
+            AdjustConstants.getAdjustHelper().onCreate(this, AdjustConstants.MOZ_INSTALL_TRACKING_ADJUST_SDK_APP_TOKEN);
+        }
+
+        if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT) {
+            DownloadContentService.startVerification(this);
+        }
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
 
     public LightweightTheme getLightweightTheme() {
         return mLightweightTheme;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/DownloadContent.java
@@ -0,0 +1,182 @@
+/* -*- 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.ContentValues;
+import android.database.Cursor;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.annotation.StringDef;
+
+public class DownloadContent {
+    @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED})
+    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
+
+    @StringDef({TYPE_ASSET})
+    public @interface Type {}
+    public static final String TYPE_ASSET = "asset";
+
+    @StringDef({KIND_FONT})
+    public @interface Kind {}
+    public static final String KIND_FONT = "font";
+
+    private final String id;
+    private final String location;
+    private final String checksum;
+    private final String 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 checksum,
+                            @NonNull String lastModified, @NonNull String type, @NonNull String kind, long size) {
+        this.id = id;
+        this.location = location;
+        this.checksum = checksum;
+        this.lastModified = lastModified;
+        this.type = type;
+        this.kind = kind;
+        this.size = size;
+        this.state = STATE_NONE;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public 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 getChecksum() {
+        return checksum;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    public boolean isFont() {
+        return KIND_FONT.equals(kind);
+    }
+
+    public boolean isAsset() {
+        return TYPE_ASSET.equals(type);
+    }
+
+    public ContentValues toContentValues() {
+        ContentValues values = new ContentValues();
+
+        values.put(LocalCatalogStorage.CatalogColumns.UID, id);
+        values.put(LocalCatalogStorage.CatalogColumns.LOCATION, location);
+        values.put(LocalCatalogStorage.CatalogColumns.CHECKSUM, checksum);
+        values.put(LocalCatalogStorage.CatalogColumns.LAST_MODIFIED, lastModified);
+        values.put(LocalCatalogStorage.CatalogColumns.TYPE, type);
+        values.put(LocalCatalogStorage.CatalogColumns.KIND, kind);
+        values.put(LocalCatalogStorage.CatalogColumns.SIZE, size);
+        values.put(LocalCatalogStorage.CatalogColumns.STATE, state);
+
+        return values;
+    }
+
+    public static DownloadContent fromCursor(Cursor cursor) {
+        return new Builder()
+            .setId(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.UID)))
+            .setLocation(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.LOCATION)))
+            .setChecksum(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.CHECKSUM)))
+            .setLastModified(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.LAST_MODIFIED)))
+            .setType(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.TYPE)))
+            .setKind(cursor.getString(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.KIND)))
+            .setSize(cursor.getLong(cursor.getColumnIndexOrThrow(LocalCatalogStorage.CatalogColumns.SIZE)))
+            .setState(cursor.getInt(cursor.getColumnIndex(LocalCatalogStorage.CatalogColumns.STATE)))
+            .build();
+    }
+
+    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 checksum;
+        private String lastModified;
+        private String type;
+        private String kind;
+        private long size;
+        private int state;
+
+        public DownloadContent build() {
+            DownloadContent content = new DownloadContent(id, location, checksum, 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 setChecksum(String checksum) {
+            this.checksum = checksum;
+            return this;
+        }
+
+        public Builder setLastModified(String 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/DownloadContentBootstrap.java
@@ -0,0 +1,135 @@
+/* -*- 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 java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ *
+ */
+public class DownloadContentBootstrap {
+    // TODO: These are all just test URLs!!
+    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("https://people.mozilla.org/~skaspari/packages/fonts/CharisSILCompact-B.ttf")
+                        .setChecksum("a33aed1a5806b231f780ccf37756752d")
+                        .setSize(1676072)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("68c6472d-94a6-4fb2-8525-78e427b022fe")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/CharisSILCompact-BI.ttf")
+                        .setChecksum("6ab927bff85e5bd73ede9021fd6cf226")
+                        .setSize(1667812)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("33d0ce0d-9c48-4a37-8b74-81cce872061b")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/CharisSILCompact-I.ttf")
+                        .setChecksum("9c40cbcda17e4b41984dfe65a1f00a81")
+                        .setSize(1693988)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("7e274cdc-4216-4dc0-b7a5-8ec333f0c287")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/CharisSILCompact-R.ttf")
+                        .setChecksum("8971901d68bfea79832eed4c520c4621")
+                        .setSize(1727656)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("b144002f-d5de-448c-8952-da1e405e022f")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Bold.ttf")
+                        .setChecksum("a32499dccf949442f5f64e6b5af829a8")
+                        .setSize(140136)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("f07502f5-e4c5-41a8-8788-89717397a98d")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-BoldItalic.ttf")
+                        .setChecksum("3e5500df7b449ff089f17e89b80a5657")
+                        .setSize(156124)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("afafc7ef-f516-42da-88d4-d8435f65541b")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Italic.ttf")
+                        .setChecksum("e62d7adbcbe25af62beffe4af6173562")
+                        .setSize(155672)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("28521d9b-ac2e-45d0-89b6-a4c9076dbf6d")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Light.ttf")
+                        .setChecksum("bcf130f5add5030a38e308dc0de7a52d")
+                        .setSize(145976)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("13f01bf4-da71-4673-9c60-ec0e9a45c38c")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Medium.ttf")
+                        .setChecksum("025700f430ae7961f5750839dd0617ff")
+                        .setSize(148892)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("73104370-c7ee-4b5b-bb37-392a4e66f65a")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-MediumItalic.ttf")
+                        .setChecksum("05efe631bc771d5fcceab02cd5899403")
+                        .setSize(155228)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("274f3718-f6e0-40b4-b52a-44812ec3ea9e")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Regular.ttf")
+                        .setChecksum("6652faff7eb906136cb2a3bb836e4aae")
+                        .setSize(142572)
+                        .setKind("font")
+                        .setType("asset")
+                        .build(),
+
+                new DownloadContent.Builder()
+                        .setId("77803858-3cfb-4a0d-a1d3-fa1bf8a6c604")
+                        .setLocation("https://people.mozilla.org/~skaspari/packages/fonts/ClearSans-Thin.ttf")
+                        .setChecksum("1d1e5c7ba81e9451e5ccc281ee325fbb")
+                        .setSize(147004)
+                        .setKind("font")
+                        .setType("asset")
+                        .build()
+        );
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/DownloadContentHelper.java
@@ -0,0 +1,217 @@
+/* -*- 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.util.HardwareUtils;
+import org.mozilla.gecko.util.IOUtils;
+
+import android.content.Context;
+import android.net.Uri;
+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.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/* package-private */ class DownloadContentHelper {
+    private static final String LOGTAG = "GeckoDLCHelper";
+
+    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 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() {
+        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.getChecksum() + "-" + content.getId());
+    }
+
+    /* package-private */ static void download(HttpClient client, String source, File temporaryFile)
+            throws RecoverableDownloadContentException {
+        InputStream inputStream = null;
+        FileOutputStream outputStream = null;
+
+        HttpGet request = new HttpGet(source);
+
+        try {
+            HttpResponse response = client.execute(request);
+            int status = response.getStatusLine().getStatusCode();
+            if (status != HttpStatus.SC_OK) {
+                // TODO: Handle specific status codes like 404? Or always assume the file will be available eventually?
+                //       Currently we do not update the catalog so there's no way to recover from an outdated URL.
+
+                // Recoverable: Might be a server error or misconfiguration
+                throw new RecoverableDownloadContentException("Download failed. Status code: " + status);
+            }
+
+            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 FileOutputStream(temporaryFile);
+
+            IOUtils.copy(inputStream, outputStream);
+        } catch (IOException e) {
+            // TODO: We could keep the temporary file and resume download with a range header later (Follow-up?)
+            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));
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] buffer = new byte[4096];
+            int read;
+
+            while ((read = inputStream.read(buffer)) != -1) {
+                md.update(buffer, 0, read);
+            }
+
+            String actualChecksum = bytesToHex(md.digest());
+
+            if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) {
+                Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum);
+                return false;
+            }
+
+            return true;
+        } catch (NoSuchAlgorithmException e) {
+            // Unrecoverable: If we can't compute the checmsum then we can't do anything about it
+            throw new UnrecoverableDownloadContentException(e);
+        } catch (IOException e) {
+            // Recoverable: Just I/O discontinuation
+            throw new RecoverableDownloadContentException(e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+        }
+    }
+
+    /* package-private */ static void copyToDestination(Context context, DownloadContent content, File temporaryFile)
+            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+        File destinationFile = getDestinationFile(context, content);
+
+        File destinationDirectory = destinationFile.getParentFile();
+        if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+            // TODO: How to prevent re-downloading the file in this case? (
+
+            // Recoverable: Maybe the file system is not mounted now. Try again later.
+            throw new RecoverableDownloadContentException("Could not create destination directory: " + destinationDirectory);
+        }
+
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+
+        try {
+            inputStream = new BufferedInputStream(new FileInputStream(temporaryFile));
+            outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile));
+
+            IOUtils.copy(inputStream, outputStream);
+        } catch (IOException e) {
+            // Recoverable: Just I/O discontinuation
+            throw new RecoverableDownloadContentException(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"),
+                            Uri.parse(content.getLocation()).getLastPathSegment());
+        }
+
+        // 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());
+    }
+
+    final protected static char[] hexArray = "0123456789ABCDEF".toCharArray();
+    private static String bytesToHex(byte[] bytes) {
+        char[] hexChars = new char[bytes.length * 2];
+        for ( int j = 0; j < bytes.length; j++ ) {
+            int v = bytes[j] & 0xFF;
+            hexChars[j * 2] = hexArray[v >>> 4];
+            hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+        }
+        return new String(hexChars);
+    }
+}
deleted file mode 100644
--- a/mobile/android/base/dlc/DownloadContentReceiver.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/* -*- 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.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-
-/**
- * This receiver is notified when DownloadContentService has successfully downloaded content.
- */
-public class DownloadContentReceiver extends BroadcastReceiver {
-    public static void broadcastDownloadContentAvailable(Context context) {
-        Intent intent = new Intent(getBroadcastAction(context));
-        context.sendBroadcast(intent);
-    }
-
-    private static String getBroadcastAction(Context context) {
-        return context.getPackageName() + ".DOWNLOAD_CONTENT_AVAILABLE";
-    }
-
-    @Override
-    public void onReceive(Context context, Intent intent) {
-        // Currently this is only used to download fonts
-        FontsHelper.markFontsAsDownloaded(context);
-        // TODO: Notify Gecko about new fonts
-    }
-}
--- a/mobile/android/base/dlc/DownloadContentService.java
+++ b/mobile/android/base/dlc/DownloadContentService.java
@@ -1,230 +1,177 @@
 /* -*- 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.sync.Utils;
-import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
 
+import android.app.AlarmManager;
 import android.app.IntentService;
+import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
 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.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.UnsupportedEncodingException;
-import java.security.NoSuchAlgorithmException;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
 
 /**
  * This is a simple background service for downloadable content. It grabs archives from a URL and extracts them to a
  * path on the device. The service will take care of retry / interruptions.
  */
 public class DownloadContentService extends IntentService {
     private static final String LOGTAG = "GeckoDLCService";
 
-    private static final String EXTRA_SOURCE_URL = "source_url";
-    private static final String EXTRA_DESTINATION_PATH = "destination_path";
-
-    private static final String CACHE_DIRECTORY = "downloadContent";
-
-    private static class DownloadFailedException extends Exception {
-        private static final long serialVersionUID = -2246772819507370734L;
-
-        public DownloadFailedException(String message) {
-            super(message);
-        }
+    // TODO: Waiting for network connectivity / availability
+    // TODO: (Maybe) Parallel downloads / Load balancing (Later, follow-up?)
+    // TODO: Download only on WiFi?
 
-        public DownloadFailedException(Throwable cause) {
-            super(cause);
-        }
-    }
+    private final LocalCatalogStorage storage;
 
-    public static void scheduleDownload(Context context, Uri source, File path) {
-        Bundle bundle = new Bundle();
-        bundle.putString(EXTRA_SOURCE_URL, source.toString());
-        bundle.putString(EXTRA_DESTINATION_PATH, path.getAbsolutePath());
-
-        Intent intent = new Intent(context, DownloadContentService.class);
-        intent.putExtras(bundle);
+    public static void startVerification(Context context) {
+        Intent intent = new Intent(DownloadContentHelper.ACTION_VERIFY_CONTENT);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
         context.startService(intent);
     }
 
-    // TODO: Notifications after downloads...
-    // TODO: Retry
-    // TODO: Backoff
-    // TODO: Resume
-    // TODO: Waiting for network connectivity / availability
-    // TODO: (Maybe) Parallel downloads / Load balancing
-
-    private final HttpClient client;
+    public static void startDownloads(Context context) {
+        Intent intent = new Intent(DownloadContentHelper.ACTION_DOWNLOAD_CONTENT);
+        intent.setComponent(new ComponentName(context, DownloadContentService.class));
+        context.startService(intent);
+    }
 
     public DownloadContentService() {
         super(LOGTAG);
 
-        client = HttpClientBuilder.create()
-            .setUserAgent(HardwareUtils.isTablet() ?
-                          AppConstants.USER_AGENT_FENNEC_TABLET :
-                          AppConstants.USER_AGENT_FENNEC_MOBILE)
-            .setRetryHandler(new DefaultHttpRequestRetryHandler())
-            .build();
+        storage = new LocalCatalogStorage(this);
     }
 
-    @Override
     protected void onHandleIntent(Intent intent) {
-        String source = intent.getStringExtra(EXTRA_SOURCE_URL);
-        String destination = intent.getStringExtra(EXTRA_DESTINATION_PATH);
-
-        try {
-            File temporaryFile = createTemporaryFile(this, source, destination);
-
-            download(source, temporaryFile);
-            extract(temporaryFile, destination);
-
-            temporaryFile.delete();
-        } catch (DownloadFailedException e) {
-            Log.w(LOGTAG, "Downloading content failed", e);
-
-            // TODO: Schedule Retry?
+        if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT) {
+            Log.w(LOGTAG, "Download content is not enabled. Stop.");
+            return;
         }
 
-        DownloadContentReceiver.broadcastDownloadContentAvailable(this);
-    }
-
-    private File createTemporaryFile(Context context, String source, String destination) throws DownloadFailedException {
-        try {
-            String fileName = Utils.sha1Base32(source + "#" + destination);
-            File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
-
-            if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
-                throw new DownloadFailedException("Could not create cache directory: " + cacheDirectory);
-            }
-
-            return new File(cacheDirectory, fileName);
-        } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
-            throw new DownloadFailedException(e);
+        if (DownloadContentHelper.ACTION_DOWNLOAD_CONTENT.equals(intent.getAction())) {
+            downloadContent();
+        } else if (DownloadContentHelper.ACTION_VERIFY_CONTENT.equals(intent.getAction())) {
+            verifyCatalog();
         }
     }
 
-    private void download(String source, File temporaryFile) throws DownloadFailedException {
-        InputStream inputStream = null;
-        FileOutputStream  outputStream = null;
+    protected void verifyCatalog() {
+        Log.d(LOGTAG, "Verifying catalog..");
 
-        HttpGet request = new HttpGet(source);
+        for (DownloadContent content : storage.getAllContent()) {
+            switch (content.getState()) {
+                case DownloadContent.STATE_NONE:
+                    // TODO: Verify available disk space? (Follow-up?)
+                    if (content.isAsset() && content.isFont()) {
+                        // This is the only kind and type of asset we know of and we want all of them
+                        content.setState(DownloadContent.STATE_SCHEDULED);
+                        storage.update(content);
+
+                        Log.d(LOGTAG, "Scheduled download: " + content);
+                    }
+                    break;
 
+                case DownloadContent.STATE_DOWNLOADED:
+                    verifyDownloadedContent(content);
+                    break;
+            }
+        }
+
+        if (storage.getScheduledDownloads().size() > 0) {
+            startDownloads(this);
+        }
+
+        Log.v(LOGTAG, "Done");
+    }
+
+    protected void verifyDownloadedContent(DownloadContent content) {
         try {
-            HttpResponse response = client.execute(request);
-            int status = response.getStatusLine().getStatusCode();
-            if (status != HttpStatus.SC_OK && status != HttpStatus.SC_PARTIAL_CONTENT) {
-                throw new DownloadFailedException("Download failed. Status code: " + status);
+            File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
+
+            if (!destinationFile.exists()) {
+                Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content);
+                // TODO: How did this file vanish but not the database? How to prevent download/delete loops?
+                content.setState(DownloadContent.STATE_SCHEDULED);
+                storage.update(content);
             }
 
-            HttpEntity entity = response.getEntity();
-            if (entity == null) {
-                throw new DownloadFailedException("Null entity");
+            if (!DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
+                Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
+                content.setState(DownloadContent.STATE_SCHEDULED);
+                storage.update(content);
             }
 
-            inputStream = new BufferedInputStream(entity.getContent());
-            outputStream = new FileOutputStream(temporaryFile);
-
-            copy(inputStream, outputStream);
-        } catch (IOException e) {
-            throw new DownloadFailedException(e);
-        } finally {
-            closeGracefully(inputStream);
-            closeGracefully(outputStream);
+            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.
         }
     }
 
-    private void extract(File temporaryFile, String destination) throws DownloadFailedException {
-        File destinationDirectory = new File(destination);
-        if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
-            throw new DownloadFailedException("Could not create destination directory: " + destinationDirectory);
-        }
+    protected void downloadContent() {
+        Log.d(LOGTAG, "Downloading content..");
 
-        ZipInputStream zipStream = null;
-        FileOutputStream outputStream = null;
+        HttpClient client = DownloadContentHelper.buildHttpClient();
+
+        for (DownloadContent content : storage.getScheduledDownloads()) {
+            Log.d(LOGTAG, "Downloading: " + content);
 
-        try {
-            zipStream = new ZipInputStream(new BufferedInputStream(new FileInputStream(temporaryFile)));
+            try {
+                File temporaryFile = DownloadContentHelper.createTemporaryFile(this, content);
+                DownloadContentHelper.download(client, content.getLocation(), temporaryFile);
 
-            ZipEntry entry;
-
-            while ((entry = zipStream.getNextEntry()) != null) {
-                if (entry.isDirectory()) {
-                    // The current implementation just ignores directories and only extracts flat files.
+                if (!DownloadContentHelper.verify(temporaryFile, content.getChecksum())) {
+                    Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
+                    temporaryFile.delete();
                     continue;
                 }
 
-                File destinationFile = new File(destinationDirectory, entry.getName());
-                if (!destinationFile.exists()) {
-                    outputStream = new FileOutputStream(destinationFile);
-                    copy(zipStream, outputStream);
+                if (!content.isAsset()) {
+                    Log.e(LOGTAG, "Downloaded content is not of type 'asset'");
+                    continue;
+                }
+
+                // TODO: Destination File object should be handled here. We should delete it if copying has not finished
+                DownloadContentHelper.copyToDestination(this, content, temporaryFile);
+
+                if (!temporaryFile.delete()) {
+                    Log.d(LOGTAG, "Could not delete temporary file: " + temporaryFile);
+                }
+
+                content.setState(DownloadContent.STATE_DOWNLOADED);
+                storage.update(content);
 
-                    outputStream.flush();
-                    outputStream.close();
-                } else {
-                    // TODO: Always replace?
-                }
+                Log.d(LOGTAG, "Successfully downloaded: " + content);
+
+                onContentDownloaded(content);
+            } catch (DownloadContentHelper.RecoverableDownloadContentException e) {
+                Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e);
+
+                // TODO: Schedule retry using alarm manager? (Follow-up? - We are already retrying on app start)
+            } catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
+                Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
+
+                content.setState(DownloadContent.STATE_FAILED);
+                storage.update(content);
             }
-        } catch (IOException e) {
-            throw new DownloadFailedException(e);
-        } finally {
-            closeGracefully(zipStream);
-            closeGracefully(outputStream);
         }
+
+        Log.v(LOGTAG, "Done");
     }
 
-    private void copy(InputStream in, OutputStream out) throws IOException {
-        byte[] buffer = new byte[10240];
-        int len;
-
-        while ((len = in.read(buffer)) != -1) {
-            out.write(buffer, 0, len);
-        }
-    }
-
-    private void closeGracefully(InputStream stream) {
-        if (stream == null) {
-            return;
-        }
-
-        try {
-            stream.close();
-        } catch (IOException e) {
-            // Ignore
-        }
-    }
-
-    private void closeGracefully(OutputStream stream) {
-        if (stream == null) {
-            return;
-        }
-
-        try {
-            stream.close();
-        } catch (IOException e) {
-            // Ignore
+    private void onContentDownloaded(DownloadContent content) {
+        if (content.isFont()) {
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Fonts:Reload", ""));
         }
     }
 }
deleted file mode 100644
--- a/mobile/android/base/dlc/FontsHelper.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/* -*- 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.GeckoProfileDirectories;
-
-import android.content.Context;
-import android.net.Uri;
-import android.util.Log;
-
-import java.io.File;
-
-public class FontsHelper {
-    private static final String LOGTAG = "GeckoDLCFontsHelper";
-
-    private static final String PREFERENCE_FILE = "fonts_dlc";
-    private static final String KEY_FONTS_DOWNLOADED = "fonts_downloaded";
-
-    private static final Uri FONTS_DOWNLOAD_URI = Uri.parse("http://people.mozilla.org/~skaspari/downloads/fonts.zip");
-
-    public static void scheduleFontsDownloadIfNeeded(Context context) {
-        if (AppConstants.MOZ_ANDROID_EXCLUDE_FONTS && needsFonts(context)) {
-            try {
-                DownloadContentService.scheduleDownload(context,
-                        FONTS_DOWNLOAD_URI,
-                        new File(GeckoProfileDirectories.getMozillaDirectory(context), "fonts"));
-            } catch (GeckoProfileDirectories.NoMozillaDirectoryException e) {
-                Log.w(LOGTAG, "No mozilla directory. No fonts download", e);
-            }
-        }
-    }
-
-    private synchronized static boolean needsFonts(Context context) {
-        return !context.getSharedPreferences(PREFERENCE_FILE, Context.MODE_PRIVATE)
-            .getBoolean(KEY_FONTS_DOWNLOADED, false);
-    }
-
-    /* package-private */ synchronized static void markFontsAsDownloaded(Context context) {
-        context.getSharedPreferences(PREFERENCE_FILE, Context.MODE_PRIVATE)
-            .edit()
-            .putBoolean(KEY_FONTS_DOWNLOADED, true)
-            .apply();
-    }
-}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/LocalCatalogStorage.java
@@ -0,0 +1,147 @@
+/* -*- 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.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LocalCatalogStorage {
+    private final Database database;
+
+    public LocalCatalogStorage(Context context) {
+        database = new Database(context);
+    }
+
+    public List<DownloadContent> getAllContent() {
+        return queryContent(null, null);
+    }
+
+    public List<DownloadContent> getScheduledDownloads() {
+        return queryContent(CatalogColumns.STATE + " = ?",
+                            new String[] { String.valueOf(DownloadContent.STATE_SCHEDULED) });
+    }
+
+    private List<DownloadContent> queryContent(String selection, String[] selectionArgs) {
+        List<DownloadContent> content = new ArrayList<>();
+
+        synchronized (database) {
+            SQLiteDatabase db = database.getReadableDatabase();
+            Cursor cursor = null;
+
+            try {
+                cursor = db.query(CatalogColumns._TABLE_NAME,
+                                  CatalogColumns._ALL,
+                                  selection,
+                                  selectionArgs,
+                                  null,
+                                  null,
+                                  null);
+
+                while (cursor.moveToNext()) {
+                    content.add(DownloadContent.fromCursor(cursor));
+                }
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+                db.close();
+            }
+        }
+
+        return content;
+    }
+
+    public void update(DownloadContent content) {
+        synchronized (database) {
+            SQLiteDatabase db = database.getWritableDatabase();
+
+            try {
+                db.update(CatalogColumns._TABLE_NAME,
+                          content.toContentValues(),
+                          CatalogColumns.UID + " = ?",
+                          new String[] { content.getId() });
+            } finally {
+                db.close();
+            }
+        }
+    }
+
+    private static class Database extends SQLiteOpenHelper {
+        private static final String DATABASE_NAME = "downloadContentRegistry";
+        private static final int DATABASE_VERSION = 1;
+
+        public Database(Context context) {
+            super(context, DATABASE_NAME, null, DATABASE_VERSION);
+        }
+
+        @Override
+        public void onCreate(SQLiteDatabase db) {
+            db.execSQL("CREATE TABLE " + CatalogColumns._TABLE_NAME + " ( "
+                + CatalogColumns.UID + " TEXT PRIMARY KEY NOT NULL, "
+                + CatalogColumns.LOCATION + " TEXT NOT NULL, "
+                + CatalogColumns.CHECKSUM + " TEXT NOT NULL, "
+                + CatalogColumns.LAST_MODIFIED + " INTEGER, "
+                + CatalogColumns.TYPE + " TEXT NOT NULL, "
+                + CatalogColumns.KIND + " TEXT NOT NULL, "
+                + CatalogColumns.SIZE + " INTEGER NOT NULL, "
+                + CatalogColumns.STATE + " INTEGER NOT NULL"
+                + ");"
+            );
+
+            // In this first version we are populating the list of assets here. Later this will be
+            // synchronized by a service client.
+            insertInitialContentList(db);
+        }
+
+        @Override
+        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+            // Nothing to do yet
+        }
+
+        private void insertInitialContentList(SQLiteDatabase db) {
+            db.beginTransaction();
+
+            try {
+                for (DownloadContent content : DownloadContentBootstrap.createInitialDownloadContentList()) {
+                    db.insertOrThrow(CatalogColumns._TABLE_NAME, null, content.toContentValues());
+                }
+
+                db.setTransactionSuccessful();
+            } finally {
+                db.endTransaction();
+            }
+        }
+    }
+
+    public interface CatalogColumns {
+        String _TABLE_NAME = "catalog";
+
+        String UID = "uid";
+        String LOCATION = "location";
+        String CHECKSUM = "checksum";
+        String LAST_MODIFIED = "last_modified";
+        String TYPE = "type";
+        String KIND = "kind";
+        String SIZE = "size";
+        String STATE = "state";
+
+        String[] _ALL = {
+            UID,
+            LOCATION,
+            CHECKSUM,
+            LAST_MODIFIED,
+            TYPE,
+            KIND,
+            SIZE,
+            STATE
+        };
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -213,19 +213,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/DownloadContentReceiver.java',
+    'dlc/DownloadContent.java',
+    'dlc/DownloadContentBootstrap.java',
+    'dlc/DownloadContentHelper.java',
     'dlc/DownloadContentService.java',
-    'dlc/FontsHelper.java',
+    'dlc/LocalCatalogStorage.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
@@ -113,16 +113,21 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "Notifications",
                                   "resource://gre/modules/Notifications.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "GMPInstallManager",
                                   "resource://gre/modules/GMPInstallManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.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"],
@@ -441,16 +446,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() {
       NativeWindow.toast.show(Strings.browser.GetStringFromName("alertFullScreenToast"), "short");
     }
 
     window.addEventListener("fullscreen", function() {
       Messaging.sendRequest({
@@ -2017,16 +2024,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;
 
     }
   },
 
   /**
@@ -7510,8 +7521,9 @@ HTMLContextMenuItem.prototype = Object.c
         icon: elt.icon,
         label: elt.label,
         disabled: elt.disabled,
         menu: elt instanceof Ci.nsIDOMHTMLMenuElement
       };
     }
   },
 });
+