Bug 1200291 - Use a flat file instead of SQLite database for catalog. draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Fri, 25 Sep 2015 15:21:51 +0200
changeset 297235 3b1ff72be166cd5b287e72f937dfe62a6e7ed5b6
parent 297234 75e442e441e504aaeef681ff03eb8d6a918e7e52
child 509742 782673863c541fb7e2ecd24c7bdeab0d017b7ea4
push id5932
push users.kaspari@gmail.com
push dateTue, 29 Sep 2015 17:50:37 +0000
bugs1200291
milestone44.0a1
Bug 1200291 - Use a flat file instead of SQLite database for catalog. This patch replaces the database catalog with a flat file implementation based on AtomicFile and inspired by SharedPreferencesImpl. The "verify" step of the DownloadContentService is now split into two steps triggered at different times: * "Study": Scan the catalog for "new" content available for download. * "Verify": Validate downloaded content. Does it still exist and does it have the correct checksum?
configure.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/DownloadContentService.java
mobile/android/base/dlc/LocalCatalogStorage.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
--- a/configure.in
+++ b/configure.in
@@ -3946,27 +3946,27 @@ 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.
+# Whether to enable the background service for downloading 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)
+MOZ_ARG_DISABLE_BOOL(android-download-conten-service,
+[  --disable-android-download-content-service
+                          Disable service for downloading additional content (fonts, hyphenation dictionaries, ..) at runtime],
+    MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE=1)
+
+if test -n "$MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE"; then
+    AC_DEFINE(MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE)
+fi
+AC_SUBST(MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE)
 
 # 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)
 
--- a/mobile/android/base/AppConstants.java.in
+++ b/mobile/android/base/AppConstants.java.in
@@ -340,15 +340,15 @@ public class AppConstants {
 
     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
+    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
@@ -1,29 +1,29 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
-import org.mozilla.gecko.AdjustConstants;
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.DynamicToolbar.PinReason;
 import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 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.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;
@@ -1951,16 +1951,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
@@ -156,18 +156,18 @@ public class GeckoApplication extends Ap
         });
 
         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);
+        if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+            DownloadContentService.startStudy(this);
         }
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
 
     public LightweightTheme getLightweightTheme() {
--- a/mobile/android/base/dlc/DownloadContentHelper.java
+++ b/mobile/android/base/dlc/DownloadContentHelper.java
@@ -1,21 +1,25 @@
 /* -*- 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.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.net.Uri;
+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;
@@ -30,16 +34,17 @@ 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_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 CACHE_DIRECTORY = "downloadContent";
 
     /**
      * Exception indicating a recoverable error has happened. Download of the content will be retried later.
      */
@@ -67,16 +72,17 @@ import java.security.NoSuchAlgorithmExce
         }
 
         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();
     }
 
@@ -98,35 +104,35 @@ import java.security.NoSuchAlgorithmExce
         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.
+                // We are assuming that this file should be available and a non-200 status code is only a temporary
+                // misbehaving server.
 
                 // 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?)
+            // 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);
         }
@@ -141,77 +147,69 @@ import java.security.NoSuchAlgorithmExce
             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());
+            String actualChecksum = Utils.byte2Hex(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
+            // Unrecoverable: If we can't compute the checksum 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)
+    /* package-private */ static void copy(File temporaryFile, File destinationFile)
             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 {
+            File destinationDirectory = destinationFile.getParentFile();
+            if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+                throw new IOException("Destination directory does not exist and cannot be created: " + destinationFile);
+            }
+
             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);
+            // 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"),
                             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);
+    /* package-private */ static boolean isActiveNetworkMetered(Context context) {
+        return ConnectivityManagerCompat.isActiveNetworkMetered(
+                (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
     }
 }
--- a/mobile/android/base/dlc/DownloadContentService.java
+++ b/mobile/android/base/dlc/DownloadContentService.java
@@ -3,171 +3,217 @@
  * 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.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 
-import android.app.AlarmManager;
 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;
 
 /**
- * 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.
+ * Service to handle downloadable content that did not ship with the APK.
  */
 public class DownloadContentService extends IntentService {
     private static final String LOGTAG = "GeckoDLCService";
 
-    // TODO: Waiting for network connectivity / availability
-    // TODO: (Maybe) Parallel downloads / Load balancing (Later, follow-up?)
-    // TODO: Download only on WiFi?
-
-    private final LocalCatalogStorage storage;
+    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);
+    }
 
-        storage = new LocalCatalogStorage(this);
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        catalog = new DownloadContentCatalog(this);
     }
 
     protected void onHandleIntent(Intent intent) {
-        if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT) {
+        if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
             Log.w(LOGTAG, "Download content is not enabled. Stop.");
             return;
         }
 
-        if (DownloadContentHelper.ACTION_DOWNLOAD_CONTENT.equals(intent.getAction())) {
+        if (intent == null) {
+            return;
+        }
+
+        if (DownloadContentHelper.ACTION_STUDY_CATALOG.equals(intent.getAction())) {
+            studyCatalog();
+        } else if (DownloadContentHelper.ACTION_DOWNLOAD_CONTENT.equals(intent.getAction())) {
             downloadContent();
         } else if (DownloadContentHelper.ACTION_VERIFY_CONTENT.equals(intent.getAction())) {
             verifyCatalog();
+        } else {
+            Log.e(LOGTAG, "Unknown action: " + intent.getAction());
         }
+
+        catalog.persistChanges();
     }
 
-    protected void verifyCatalog() {
-        Log.d(LOGTAG, "Verifying catalog..");
+    /**
+     * Study: Scan the catalog for "new" content available for download.
+     */
+    private void studyCatalog() {
+        Log.d(LOGTAG, "Studying catalog..");
 
-        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);
+        for (DownloadContent content : catalog.getContentWithoutState()) {
+            if (content.isAsset() && content.isFont()) {
+                catalog.scheduleDownload(content);
 
-                        Log.d(LOGTAG, "Scheduled download: " + content);
-                    }
-                    break;
+                Log.d(LOGTAG, "Scheduled download: " + content);
+            } else {
+                catalog.markAsIgnored(content);
 
-                case DownloadContent.STATE_DOWNLOADED:
-                    verifyDownloadedContent(content);
-                    break;
+                Log.d(LOGTAG, "Ignoring: " + content);
             }
         }
 
-        if (storage.getScheduledDownloads().size() > 0) {
+        if (catalog.hasScheduledDownloads()) {
             startDownloads(this);
         }
 
         Log.v(LOGTAG, "Done");
     }
 
-    protected void verifyDownloadedContent(DownloadContent content) {
-        try {
-            File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
+    /**
+     * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+     */
+    private void verifyCatalog() {
+        Log.d(LOGTAG, "Verifying catalog..");
 
-            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);
-            }
+        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())) {
-                Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
-                content.setState(DownloadContent.STATE_SCHEDULED);
-                storage.update(content);
-            }
+                if (!DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
+                    catalog.scheduleDownload(content);
+                    Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
+                }
 
-            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.
+                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");
     }
 
-    protected void downloadContent() {
+    /**
+     * 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 : storage.getScheduledDownloads()) {
+        for (DownloadContent content : catalog.getScheduledDownloads()) {
             Log.d(LOGTAG, "Downloading: " + content);
 
+            File temporaryFile = null;
+            File destinationFile = null;
+
             try {
-                File temporaryFile = DownloadContentHelper.createTemporaryFile(this, content);
+                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);
                 DownloadContentHelper.download(client, content.getLocation(), temporaryFile);
 
                 if (!DownloadContentHelper.verify(temporaryFile, content.getChecksum())) {
                     Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
                     temporaryFile.delete();
                     continue;
                 }
 
                 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);
+                DownloadContentHelper.copy(temporaryFile, destinationFile);
 
-                if (!temporaryFile.delete()) {
-                    Log.d(LOGTAG, "Could not delete temporary file: " + temporaryFile);
-                }
-
-                content.setState(DownloadContent.STATE_DOWNLOADED);
-                storage.update(content);
+                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: Schedule retry using alarm manager? (Follow-up? - We are already retrying on app start)
+                // TODO: Reschedule download (bug 1209498)
             } catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
                 Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
 
-                content.setState(DownloadContent.STATE_FAILED);
-                storage.update(content);
+                catalog.markAsPermanentlyFailed(content);
+
+                if (destinationFile != null) {
+                    // Delete potentially partial file at destination
+                    destinationFile.delete();
+                }
+            } finally {
+                if (temporaryFile != null) {
+                    temporaryFile.delete();
+                }
             }
         }
 
         Log.v(LOGTAG, "Done");
     }
 
     private void onContentDownloaded(DownloadContent content) {
         if (content.isFont()) {
deleted file mode 100644
--- a/mobile/android/base/dlc/LocalCatalogStorage.java
+++ /dev/null
@@ -1,147 +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.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
-        };
-    }
-}
rename from mobile/android/base/dlc/DownloadContent.java
rename to mobile/android/base/dlc/catalog/DownloadContent.java
--- a/mobile/android/base/dlc/DownloadContent.java
+++ b/mobile/android/base/dlc/catalog/DownloadContent.java
@@ -1,63 +1,74 @@
 /* -*- 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;
+package org.mozilla.gecko.dlc.catalog;
 
-import android.content.ContentValues;
-import android.database.Cursor;
 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 {
-    @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED})
+    private static final String KEY_ID = "id";
+    private static final String KEY_LOCATION = "location";
+    private static final String KEY_CHECKSUM = "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})
     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 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 checksum,
-                            @NonNull String lastModified, @NonNull String type, @NonNull String kind, long size) {
+                            @NonNull long 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) {
+    /* package-private */ void setState(@State int state) {
         this.state = state;
     }
 
     @State
     public int getState() {
         return state;
     }
 
@@ -86,53 +97,51 @@ public class DownloadContent {
     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 fromJSON(JSONObject object) throws JSONException {
+        return new Builder()
+            .setId(object.getString(KEY_ID))
+            .setLocation(object.getString(KEY_LOCATION))
+            .setChecksum(object.getString(KEY_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 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 JSONObject toJSON() throws JSONException {
+        JSONObject object = new JSONObject();
+        object.put(KEY_ID, id);
+        object.put(KEY_LOCATION, location);
+        object.put(KEY_CHECKSUM, checksum);
+        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 checksum;
-        private String lastModified;
+        private long 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);
@@ -149,17 +158,17 @@ public class DownloadContent {
             return this;
         }
 
         public Builder setChecksum(String checksum) {
             this.checksum = checksum;
             return this;
         }
 
-        public Builder setLastModified(String lastModified) {
+        public Builder setLastModified(long lastModified) {
             this.lastModified = lastModified;
             return this;
         }
 
         public Builder setType(String type) {
             this.type = type;
             return this;
         }
rename from mobile/android/base/dlc/DownloadContentBootstrap.java
rename to mobile/android/base/dlc/catalog/DownloadContentBootstrap.java
--- a/mobile/android/base/dlc/DownloadContentBootstrap.java
+++ b/mobile/android/base/dlc/catalog/DownloadContentBootstrap.java
@@ -1,25 +1,23 @@
 /* -*- 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;
+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;
 
-/**
- *
- */
-public class DownloadContentBootstrap {
+/* package-private */ 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 file mode 100644
--- /dev/null
+++ b/mobile/android/base/dlc/catalog/DownloadContentCatalog.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.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", e);
+        } 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
@@ -215,19 +215,19 @@ gbjar.sources += [
     'db/URLMetadata.java',
     'db/URLMetadataTable.java',
     'DevToolsAuthHelper.java',
     'distribution/Distribution.java',
     'distribution/ReferrerDescriptor.java',
     'distribution/ReferrerReceiver.java',
     'dlc/DownloadContent.java',
     'dlc/DownloadContentBootstrap.java',
+    'dlc/DownloadContentCatalog.java',
     'dlc/DownloadContentHelper.java',
     'dlc/DownloadContentService.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',