Bug 1290014 - Restructure icon code and use disk lru cache. r=ahunt,Grisha
authorSebastian Kaspari <s.kaspari@gmail.com>
Tue, 16 Aug 2016 11:36:22 +0200
changeset 353646 7d65390d95e755b5cfd6896e0f564b41e6f47e7f
parent 353645 1e8ebfb60a17b01fa25bd8557715a9002faf675f
child 353647 a803f062653e7554b50fc1d3b145fe71ec5447e3
push id6570
push userraliiev@mozilla.com
push dateMon, 14 Nov 2016 12:26:13 +0000
treeherdermozilla-beta@f455459b2ae5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersahunt, Grisha
bugs1290014, 1269821, 1271634
milestone51.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1290014 - Restructure icon code and use disk lru cache. r=ahunt,Grisha This patch does multiple things: 1) It restructures the icon code to follow a preparer, loader, processor pattern. Instead of very long procedures we now have a lot of small components. This patch includes 90+ tests for those components. 2) It replaces the database storage with the disk lru cache. We still keep the tables around because we will still load from it as fallback to avoid needing to migrate all data. This patch is pretty big but a lot of it is moving code around and breaking it into smaller chunks. A later commit will remove now obsolete components. By creating a consistent mapping page URL -> icon URL -> icon data this change fixes the linked bugs (bug 1269821 and bug 1271634). MozReview-Commit-ID: 1nkrZn286Gv
mobile/android/base/java/org/mozilla/gecko/AboutPages.java
mobile/android/base/java/org/mozilla/gecko/favicons/FaviconGenerator.java
mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java
mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java
mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java
mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java
mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java
mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java
mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java
mobile/android/base/java/org/mozilla/gecko/icons/Icons.java
mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java
mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java
mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java
mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java
mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java
mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java
mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java
mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java
mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java
mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java
mobile/android/base/moz.build
mobile/android/tests/background/junit4/src/org/mozilla/gecko/favicons/TestFaviconGenerator.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java
--- a/mobile/android/base/java/org/mozilla/gecko/AboutPages.java
+++ b/mobile/android/base/java/org/mozilla/gecko/AboutPages.java
@@ -67,33 +67,27 @@ public class AboutPages {
         return isAboutPage(PRIVATEBROWSING, url);
     }
 
     public static boolean isAboutPage(String page, String url) {
         return url != null && url.toLowerCase().startsWith(page);
 
     }
 
-    private static final String[] DEFAULT_ICON_PAGES = new String[] {
+    public static final String[] DEFAULT_ICON_PAGES = new String[] {
+        HOME,
         ACCOUNTS,
         ADDONS,
         CONFIG,
         DOWNLOADS,
         FIREFOX,
         HEALTHREPORT,
         UPDATER
     };
 
-    /**
-     * Callers must not modify the returned array.
-     */
-    public static String[] getDefaultIconPages() {
-        return DEFAULT_ICON_PAGES;
-    }
-
     public static boolean isBuiltinIconPage(final String url) {
         if (url == null ||
             !url.startsWith("about:")) {
             return false;
         }
 
         // about:home uses a separate search built-in icon.
         if (isAboutHome(url)) {
--- a/mobile/android/base/java/org/mozilla/gecko/favicons/FaviconGenerator.java
+++ b/mobile/android/base/java/org/mozilla/gecko/favicons/FaviconGenerator.java
@@ -48,47 +48,59 @@ public class FaviconGenerator {
     private static final String[] COMMON_PREFIXES = {
             "www.",
             "m.",
             "mobile.",
     };
 
     private static final int TEXT_SIZE_DP = 12;
 
+    public static class IconWithColor {
+        public final Bitmap bitmap;
+        public final int color;
+
+        private IconWithColor(Bitmap bitmap, int color) {
+            this.bitmap = bitmap;
+            this.color = color;
+        }
+    }
+
     /**
      * Asynchronously generate default favicon for the given page URL.
      */
     public static void generate(final Context context, final String pageURL, final OnFaviconLoadedListener listener) {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                final Bitmap bitmap = generate(context, pageURL);
+                final Bitmap bitmap = generate(context, pageURL).bitmap;
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
                     public void run() {
                         listener.onFaviconLoaded(pageURL, null, bitmap);
                     }
                 });
             }
         });
     }
 
     /**
      * Generate default favicon for the given page URL.
      */
-    public static Bitmap generate(Context context, String pageURL) {
+    public static IconWithColor generate(Context context, String pageURL) {
         final Resources resources = context.getResources();
         final int widthAndHeight = resources.getDimensionPixelSize(R.dimen.favicon_bg);
         final int roundedCorners = resources.getDimensionPixelOffset(R.dimen.favicon_corner_radius);
 
         final Bitmap favicon = Bitmap.createBitmap(widthAndHeight, widthAndHeight, Bitmap.Config.ARGB_8888);
         final Canvas canvas = new Canvas(favicon);
 
+        final int color = pickColor(pageURL);
+
         final Paint paint = new Paint();
-        paint.setColor(pickColor(pageURL));
+        paint.setColor(color);
 
         canvas.drawRoundRect(new RectF(0, 0, widthAndHeight, widthAndHeight), roundedCorners, roundedCorners, paint);
 
         paint.setColor(Color.WHITE);
 
         final String character = getRepresentativeCharacter(pageURL);
 
         final float textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TEXT_SIZE_DP, context.getResources().getDisplayMetrics());
@@ -97,17 +109,17 @@ public class FaviconGenerator {
         paint.setTextSize(textSize);
         paint.setAntiAlias(true);
 
         canvas.drawText(character,
                 canvas.getWidth() / 2,
                 (int) ((canvas.getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)),
                 paint);
 
-        return favicon;
+        return new IconWithColor(favicon, color);
     }
 
     /**
      * Get a representative character for the given URL.
      *
      * For example this method will return "f" for "http://m.facebook.com/foobar".
      */
     protected static String getRepresentativeCharacter(String url) {
--- a/mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
+++ b/mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
@@ -492,17 +492,17 @@ public class Favicons {
         // downscaled to this size or discarded.
         largestFaviconSize = res.getDimensionPixelSize(R.dimen.favicon_largest_interesting_size);
 
         browserToolbarFaviconSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_favicon_size);
 
         faviconsCache = new FaviconCache(FAVICON_CACHE_SIZE_BYTES, largestFaviconSize);
 
         // Initialize page mappings for each of our special pages.
-        for (String url : AboutPages.getDefaultIconPages()) {
+        for (String url : AboutPages.DEFAULT_ICON_PAGES) {
             pageURLMappings.putWithoutEviction(url, BUILT_IN_FAVICON_URL);
         }
 
         // Load and cache the built-in favicon in each of its sizes.
         // TODO: don't open the zip twice!
         List<Bitmap> toInsert = Arrays.asList(loadBrandingBitmap(context, "favicon64.png"),
                                               loadBrandingBitmap(context, "favicon32.png"));
 
--- a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
@@ -209,17 +209,17 @@ public class TopSitesGridItemView extend
         mThumbnailView.setImageResource(resId);
         mThumbnailView.setBackgroundColor(0x0);
         mThumbnailSet = false;
     }
 
     private void generateDefaultIcon() {
         ThreadUtils.assertOnBackgroundThread();
 
-        final Bitmap bitmap = FaviconGenerator.generate(getContext(), mUrl);
+        final Bitmap bitmap = FaviconGenerator.generate(getContext(), mUrl).bitmap;
         final int dominantColor = BitmapUtils.getDominantColor(bitmap);
 
         ThreadUtils.postToUiThread(new Runnable() {
             @Override
             public void run() {
                 mThumbnailView.setScaleType(SCALE_TYPE_FAVICON);
                 mThumbnailView.setImageBitmap(bitmap);
                 mThumbnailView.setBackgroundColor(0x7FFFFFFF & dominantColor); // 50% dominant color
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java
@@ -0,0 +1,13 @@
+/* -*- 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.icons;
+
+/**
+ * Interface for a callback that will be executed once an icon has been loaded successfully.
+ */
+public interface IconCallback {
+    void onIconResponse(IconResponse response);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
@@ -0,0 +1,96 @@
+/* -*- 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.icons;
+
+import android.support.annotation.IntDef;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+/**
+ * A class describing the location and properties of an icon that can be loaded.
+ */
+public class IconDescriptor {
+    @IntDef({ TYPE_GENERIC, TYPE_FAVICON, TYPE_TOUCHICON, TYPE_LOOKUP })
+    @interface IconType {}
+
+    // The type values are used for ranking icons (higher values = try to load first).
+    @VisibleForTesting static final int TYPE_GENERIC = 0;
+    @VisibleForTesting static final int TYPE_LOOKUP = 1;
+    @VisibleForTesting static final int TYPE_FAVICON = 5;
+    @VisibleForTesting static final int TYPE_TOUCHICON = 10;
+
+    private final String url;
+    private final int size;
+    private final String mimeType;
+    private final int type;
+
+    /**
+     * Create a generic icon located at the given URL. No MIME type or size is known.
+     */
+    public static IconDescriptor createGenericIcon(String url) {
+        return new IconDescriptor(TYPE_GENERIC, url, 0, null);
+    }
+
+    /**
+     * Create a favicon located at the given URL and with a known size and MIME type.
+     */
+    public static IconDescriptor createFavicon(String url, int size, String mimeType) {
+        return new IconDescriptor(TYPE_FAVICON, url, size, mimeType);
+    }
+
+    /**
+     * Create a touch icon located at the given URL and with a known MIME type and size.
+     */
+    public static IconDescriptor createTouchicon(String url, int size, String mimeType) {
+        return new IconDescriptor(TYPE_TOUCHICON, url, size, mimeType);
+    }
+
+    /**
+     * Create an icon located at an URL that has been returned from a disk or memory storage. This
+     * is an icon with an URL we loaded an icon from previously. Therefore we give it a little higher
+     * ranking than a generic icon - even though we do not know the MIME type or size of the icon.
+     */
+    public static IconDescriptor createLookupIcon(String url) {
+        return new IconDescriptor(TYPE_LOOKUP, url, 0, null);
+    }
+
+    private IconDescriptor(@IconType int type, String url, int size, String mimeType) {
+        this.type = type;
+        this.url = url;
+        this.size = size;
+        this.mimeType = mimeType;
+    }
+
+    /**
+     * Get the URL of the icon.
+     */
+    public String getUrl() {
+        return url;
+    }
+
+    /**
+     * Get the (assumed) size of the icon. Returns 0 if no size is known.
+     */
+    public int getSize() {
+        return size;
+    }
+
+    /**
+     * Get the type of the icon (favicon, touch icon, generic, lookup).
+     */
+    @IconType
+    public int getType() {
+        return type;
+    }
+
+    /**
+     * Get the (assumed) MIME type of the icon. Returns null if no MIME type is known.
+     */
+    @Nullable
+    public String getMimeType() {
+        return mimeType;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java
@@ -0,0 +1,65 @@
+/* -*- 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.icons;
+
+import java.util.Comparator;
+
+/**
+ * This comparator implementation compares IconDescriptor objects in order to determine which icon
+ * to load first.
+ *
+ * In general this comparator will try touch icons before favicons (they usually have a higher resolution)
+ * and prefers larger icons over smaller ones.
+ */
+/* package-private */ class IconDescriptorComparator implements Comparator<IconDescriptor> {
+    @Override
+    public int compare(IconDescriptor lhs, IconDescriptor rhs) {
+        if (lhs.getUrl().equals(rhs.getUrl())) {
+            // Two descriptors pointing to the same URL are always referencing the same icon. So treat
+            // them as equal.
+            return 0;
+        }
+
+        // First compare the types. We prefer touch icons because they tend to have a higher resolution
+        // than ordinary favicons.
+        if (lhs.getType() != rhs.getType()) {
+            return compareType(lhs, rhs);
+        }
+
+        // If one of them is larger than pick the larger icon.
+        if (lhs.getSize() != rhs.getSize()) {
+            return compareSizes(lhs, rhs);
+        }
+
+        // If there's no other way to choose, we prefer container types. They *might* contain
+        // an image larger than the size given in the <link> tag.
+        final boolean lhsContainer = IconsHelper.isContainerType(lhs.getMimeType());
+        final boolean rhsContainer = IconsHelper.isContainerType(rhs.getMimeType());
+
+        if (lhsContainer != rhsContainer) {
+            return lhsContainer ? -1 : 1;
+        }
+
+        // There's no way to know which icon might be better. Just pick rhs.
+        return 1;
+    }
+
+    private int compareType(IconDescriptor lhs, IconDescriptor rhs) {
+        if (lhs.getType() > rhs.getType()) {
+            return -1;
+        } else {
+            return 1;
+        }
+    }
+
+    private int compareSizes(IconDescriptor lhs, IconDescriptor rhs) {
+        if (lhs.getSize() > rhs.getSize()) {
+            return -1;
+        } else {
+            return 1;
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java
@@ -0,0 +1,168 @@
+/* -*- 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.icons;
+
+import android.content.Context;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.R;
+
+import java.util.Iterator;
+import java.util.TreeSet;
+import java.util.concurrent.Future;
+
+/**
+ * A class describing a request to load an icon for a website.
+ */
+public class IconRequest {
+    private Context context;
+
+    // Those values are written by the IconRequestBuilder class.
+    /* package-private */ String pageUrl;
+    /* package-private */ boolean privileged;
+    /* package-private */ TreeSet<IconDescriptor> icons;
+    /* package-private */ boolean skipNetwork;
+    /* package-private */ boolean backgroundThread;
+    /* package-private */ boolean skipDisk;
+    /* package-private */ boolean skipMemory;
+    private IconCallback callback;
+    private int targetSize;
+
+    /* package-private */ IconRequest(Context context) {
+        this.context = context.getApplicationContext();
+        this.icons = new TreeSet<>(new IconDescriptorComparator());
+
+        // Setting some sensible defaults.
+        this.privileged = false;
+        this.skipMemory = false;
+        this.skipDisk = false;
+        this.skipNetwork = false;
+        this.targetSize = context.getResources().getDimensionPixelSize(R.dimen.favicon_bg);
+    }
+
+    /**
+     * Execute this request and try to load an icon. Once an icon has been loaded successfully the
+     * callback will be executed.
+     *
+     * The returned Future can be used to cancel the job.
+     */
+    public Future<IconResponse> execute(IconCallback callback) {
+        setCallback(callback);
+
+        return IconRequestExecutor.submit(this);
+    }
+
+    @VisibleForTesting void setCallback(IconCallback callback) {
+        this.callback = callback;
+    }
+
+    /**
+     * Get the (application) context associated with this request.
+     */
+    public Context getContext() {
+        return context;
+    }
+
+    /**
+     * Get the descriptor for the potentially best icon. This is the icon that should be loaded if
+     * possible.
+     */
+    public IconDescriptor getBestIcon() {
+        return icons.first();
+    }
+
+    /**
+     * Get the URL of the page for which an icon should be loaded.
+     */
+    public String getPageUrl() {
+        return pageUrl;
+    }
+
+    /**
+     * Is this request allowed to load icons from internal data sources like the omni.ja?
+     */
+    public boolean isPrivileged() {
+        return privileged;
+    }
+
+    /**
+     * Get the number of icon descriptors associated with this request.
+     */
+    public int getIconCount() {
+        return icons.size();
+    }
+
+    /**
+     * Get the required target size of the icon.
+     */
+    public int getTargetSize() {
+        return targetSize;
+    }
+
+    /**
+     * Should a loader access the network to load this icon?
+     */
+    public boolean shouldSkipNetwork() {
+        return skipNetwork;
+    }
+
+    /**
+     * Should a loader access the disk to load this icon?
+     */
+    public boolean shouldSkipDisk() {
+        return skipDisk;
+    }
+
+    /**
+     * Should a loader access the memory cache to load this icon?
+     */
+    public boolean shouldSkipMemory() {
+        return skipMemory;
+    }
+
+    /**
+     * Get an iterator to iterate over all icon descriptors associated with this request.
+     */
+    public Iterator<IconDescriptor> getIconIterator() {
+        return icons.iterator();
+    }
+
+    /**
+     * Create a builder to modify this request.
+     *
+     * Calling methods on the builder will modify this object and not create a copy.
+     */
+    public IconRequestBuilder modify() {
+        return new IconRequestBuilder(this);
+    }
+
+    /**
+     * Should the callback be executed on a background thread? By default a callback is always
+     * executed on the UI thread because an icon is usually loaded in order to display it somewhere
+     * in the UI.
+     */
+    /* package-private */ boolean shouldRunOnBackgroundThread() {
+        return backgroundThread;
+    }
+
+    /* package-private */ IconCallback getCallback() {
+        return callback;
+    }
+
+    /* package-private */ boolean hasIconDescriptors() {
+        return !icons.isEmpty();
+    }
+
+    /**
+     * Move to the next icon. This method is called after all loaders for the current best icon
+     * have failed. After calling this method getBestIcon() will return the next icon to try.
+     * hasIconDescriptors() should be called before requesting the next icon.
+     */
+    /* package-private */ void moveToNextIcon() {
+        icons.remove(getBestIcon());
+    }
+
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java
@@ -0,0 +1,122 @@
+/* -*- 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.icons;
+
+import android.content.Context;
+import android.support.annotation.CheckResult;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Builder for creating a request to load an icon.
+ */
+public class IconRequestBuilder {
+    private final IconRequest request;
+
+    /* package-private */ IconRequestBuilder(Context context) {
+        this(new IconRequest(context));
+    }
+
+    /* package-private */ IconRequestBuilder(IconRequest request) {
+        this.request = request;
+    }
+
+    /**
+     * Set the URL of the page for which the icon should be loaded.
+     */
+    @CheckResult
+    public IconRequestBuilder pageUrl(String pageUrl) {
+        request.pageUrl = pageUrl;
+        return this;
+    }
+
+    /**
+     * Set whether this request is allowed to load icons from non http(s) URLs (e.g. the omni.ja).
+     *
+     * For example web content referencing internal URLs should not lead to us loading icons from
+     * internal data structures like the omni.ja.
+     */
+    @CheckResult
+    public IconRequestBuilder privileged(boolean privileged) {
+        request.privileged = privileged;
+        return this;
+    }
+
+    /**
+     * Add an icon descriptor describing the location and properties of an icon. All descriptors
+     * will be ranked and tried in order of their rank. Executing the request will modify the list
+     * of icons (filter or add additional descriptors).
+     */
+    @CheckResult
+    public IconRequestBuilder icon(IconDescriptor descriptor) {
+        request.icons.add(descriptor);
+        return this;
+    }
+
+    /**
+     * Skip the network and do not load an icon from a network connection.
+     */
+    @CheckResult
+    public IconRequestBuilder skipNetwork() {
+        request.skipNetwork = true;
+        return this;
+    }
+
+    /**
+     * Skip the disk cache and do not load an icon from disk.
+     */
+    @CheckResult
+    public IconRequestBuilder skipDisk() {
+        request.skipDisk = true;
+        return this;
+    }
+
+    /**
+     * Skip the memory cache and do not return a previously loaded icon.
+     */
+    @CheckResult
+    public IconRequestBuilder skipMemory() {
+        request.skipMemory = true;
+        return this;
+    }
+
+    /**
+     * Execute the callback on the background thread. By default the callback is always executed on
+     * the UI thread in order to add the loaded icon to a view easily.
+     */
+    @CheckResult
+    public IconRequestBuilder executeCallbackOnBackgroundThread() {
+        request.backgroundThread = true;
+        return this;
+    }
+
+    /**
+     * Return the request built with this builder.
+     */
+    @CheckResult
+    public IconRequest build() {
+        if (TextUtils.isEmpty(request.pageUrl)) {
+            throw new IllegalStateException("Page URL is required");
+        }
+
+        return request;
+    }
+
+    /**
+     * This is a no-op method.
+     *
+     * All builder methods are annotated with @CheckResult to denote that the
+     * methods return the builder object and that it is typically an error to not call another method
+     * on it until eventually calling build().
+     *
+     * However in some situations code can keep a reference
+     * to the builder object and call methods only when a specific event occurs. To make this explicit
+     * and avoid lint errors this method can be called.
+     */
+    public void deferBuild() {
+        // No op
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java
@@ -0,0 +1,128 @@
+/* -*- 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.icons;
+
+import org.mozilla.gecko.icons.loader.ContentProviderLoader;
+import org.mozilla.gecko.icons.loader.DataUriLoader;
+import org.mozilla.gecko.icons.loader.IconDownloader;
+import org.mozilla.gecko.icons.loader.IconGenerator;
+import org.mozilla.gecko.icons.loader.JarLoader;
+import org.mozilla.gecko.icons.loader.LegacyLoader;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.loader.MemoryLoader;
+import org.mozilla.gecko.icons.loader.DiskLoader;
+import org.mozilla.gecko.icons.preparation.AboutPagesPreparer;
+import org.mozilla.gecko.icons.preparation.AddDefaultIconUrl;
+import org.mozilla.gecko.icons.preparation.FilterKnownFailureUrls;
+import org.mozilla.gecko.icons.preparation.FilterMimeTypes;
+import org.mozilla.gecko.icons.preparation.FilterPrivilegedUrls;
+import org.mozilla.gecko.icons.preparation.LookupIconUrl;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.ColorProcessor;
+import org.mozilla.gecko.icons.processing.MemoryProcessor;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.mozilla.gecko.icons.processing.ResizingProcessor;
+import org.mozilla.gecko.icons.processing.DiskProcessor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+/**
+ * Executor for icon requests.
+ */
+/* package-private */ class IconRequestExecutor {
+    /**
+     * Loader implementation that generates an icon if none could be loaded.
+     */
+    private static final IconLoader GENERATOR = new IconGenerator();
+
+    /**
+     * Ordered list of prepares that run before any icon is loaded.
+     */
+    private static final List<Preparer> PREPARERS = Arrays.asList(
+            // First we look into our memory and disk caches if there are some known icon URLs for
+            // the page URL of the request.
+            new LookupIconUrl(),
+
+            // For all icons with MIME type we filter entries with unknown MIME type that we probably
+            // cannot decode anyways.
+            new FilterMimeTypes(),
+
+            // If this is not a request that is allowed to load icons from privileged locations (omni.jar)
+            // then filter such icon URLs.
+            new FilterPrivilegedUrls(),
+
+            // This preparer adds an icon URL for about pages. It's added after the filter for privileged
+            // URLs. We always want to be able to load those specific icons.
+            new AboutPagesPreparer(),
+
+            // Add the default favicon URL (*/favicon.ico) to the list of icon URLs; with a low priority,
+            // this icon URL should be tried last.
+            new AddDefaultIconUrl(),
+
+            // Finally we filter all URLs that failed to load recently (4xx / 5xx errors).
+            new FilterKnownFailureUrls()
+    );
+
+    /**
+     * Ordered list of loaders. If a loader returns a response object then subsequent loaders are not run.
+     */
+    private static final List<IconLoader> LOADERS = Arrays.asList(
+            // First we try to load an icon that is already in the memory. That's cheap.
+            new MemoryLoader(),
+
+            // Try to decode the icon if it is a data: URI.
+            new DataUriLoader(),
+
+            // Try to load the icon from the omni.ha if it's a jar:jar URI.
+            new JarLoader(),
+
+            // Try to load the icon from a content provider (if applicable).
+            new ContentProviderLoader(),
+
+            // Try to load the icon from the disk cache.
+            new DiskLoader(),
+
+            // If the icon is not in any of our cashes and can't be decoded then look into the
+            // database (legacy). Maybe this icon was loaded before the new code was deployed.
+            new LegacyLoader(),
+
+            // Download the icon from the web.
+            new IconDownloader()
+    );
+
+    /**
+     * Ordered list of processors that run after an icon has been loaded.
+     */
+    private static final List<Processor> PROCESSORS = Arrays.asList(
+            // Extract the dominant color from the icon
+            new ColorProcessor(),
+
+            // Store the icon (and mapping) in the disk cache if needed
+            new DiskProcessor(),
+
+            // Resize the icon to match the target size (if possible)
+            new ResizingProcessor(),
+
+            // Store the icon in the memory cache
+            new MemoryProcessor()
+    );
+
+    private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+
+    /**
+     * Submit the request for execution.
+     */
+    /* package-private */ static Future<IconResponse> submit(IconRequest request) {
+        return EXECUTOR.submit(
+                new IconTask(request, PREPARERS, LOADERS, PROCESSORS, GENERATOR)
+        );
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java
@@ -0,0 +1,163 @@
+/* -*- 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.icons;
+
+import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+/**
+ * Response object containing a successful loaded icon and meta data.
+ */
+public class IconResponse {
+    /**
+     * Create a response for a plain bitmap.
+     */
+    public static IconResponse create(@NonNull Bitmap bitmap) {
+        return new IconResponse(bitmap);
+    }
+
+    /**
+     * Create a response for a bitmap that has been loaded from the network by requesting a specific URL.
+     */
+    public static IconResponse createFromNetwork(@NonNull Bitmap bitmap, @NonNull String url) {
+        final IconResponse response = new IconResponse(bitmap);
+        response.url = url;
+        response.fromNetwork = true;
+        return response;
+    }
+
+    /**
+     * Create a response for a generated bitmap with a dominant color.
+     */
+    public static IconResponse createGenerated(@NonNull Bitmap bitmap, int color) {
+        final IconResponse response = new IconResponse(bitmap);
+        response.color = color;
+        response.generated = true;
+        return response;
+    }
+
+    /**
+     * Create a response for a bitmap that has been loaded from the memory cache.
+     */
+    public static IconResponse createFromMemory(@NonNull Bitmap bitmap, @NonNull String url, int color) {
+        final IconResponse response = new IconResponse(bitmap);
+        response.url = url;
+        response.color = color;
+        response.fromMemory = true;
+        return response;
+    }
+
+    /**
+     * Create a response for a bitmap that has been loaded from the disk cache.
+     */
+    public static IconResponse createFromDisk(@NonNull Bitmap bitmap, @NonNull String url) {
+        final IconResponse response = new IconResponse(bitmap);
+        response.url = url;
+        response.fromDisk = true;
+        return response;
+    }
+
+    private Bitmap bitmap;
+    private int color;
+    private boolean fromNetwork;
+    private boolean fromMemory;
+    private boolean fromDisk;
+    private boolean generated;
+    private String url;
+
+    private IconResponse(@NonNull Bitmap bitmap) {
+        this.bitmap = bitmap;
+        this.color = 0;
+        this.url = null;
+        this.fromNetwork = false;
+        this.fromMemory = false;
+        this.fromDisk = false;
+        this.generated = false;
+    }
+
+    /**
+     * Get the icon bitmap. This method will always return a bitmap.
+     */
+    @NonNull
+    public Bitmap getBitmap() {
+        return bitmap;
+    }
+
+    /**
+     * Get the dominant color of the icon. Will return 0 if no color could be extracted.
+     */
+    public int getColor() {
+        return color;
+    }
+
+    /**
+     * Does this response contain a dominant color?
+     */
+    public boolean hasColor() {
+        return color != 0;
+    }
+
+    /**
+     * Has this icon been loaded from the network?
+     */
+    public boolean isFromNetwork() {
+        return fromNetwork;
+    }
+
+    /**
+     * Has this icon been generated?
+     */
+    public boolean isGenerated() {
+        return generated;
+    }
+
+    /**
+     * Has this icon been loaded from memory (cache)?
+     */
+    public boolean isFromMemory() {
+        return fromMemory;
+    }
+
+    /**
+     * Has this icon been loaded from disk (cache)?
+     */
+    public boolean isFromDisk() {
+        return fromDisk;
+    }
+
+    /**
+     * Get the URL this icon has been loaded from.
+     */
+    @Nullable
+    public String getUrl() {
+        return url;
+    }
+
+    /**
+     * Does this response contain an URL from which the icon has been loaded?
+     */
+    public boolean hasUrl() {
+        return !TextUtils.isEmpty(url);
+    }
+
+    /**
+     * Update the color of this response. This method is called by processors updating meta data
+     * after the icon has been loaded.
+     */
+    public void updateColor(int color) {
+        this.color = color;
+    }
+
+    /**
+     * Update the bitmap of this response. This method is called by processors that modify the
+     * loaded icon.
+     */
+    public void updateBitmap(Bitmap bitmap) {
+        this.bitmap = bitmap;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.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.icons;
+
+import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.List;
+import java.util.concurrent.Callable;
+
+/**
+ * Task that will be run by the IconRequestExecutor for every icon request.
+ */
+/* package-private */ class IconTask implements Callable<IconResponse> {
+    private static final String LOGTAG = "Gecko/IconTask";
+    private static final boolean DEBUG = true;
+
+    private final List<Preparer> preparers;
+    private final List<IconLoader> loaders;
+    private final List<Processor> processors;
+    private final IconLoader generator;
+    private final IconRequest request;
+
+    /* package-private */ IconTask(
+            @NonNull IconRequest request,
+            @NonNull List<Preparer> preparers,
+            @NonNull List<IconLoader> loaders,
+            @NonNull List<Processor> processors,
+            @NonNull IconLoader generator) {
+        this.request = request;
+        this.preparers = preparers;
+        this.loaders = loaders;
+        this.processors = processors;
+        this.generator = generator;
+    }
+
+    @Override
+    public IconResponse call() {
+        try {
+            logRequest(request);
+
+            prepareRequest(request);
+
+            final IconResponse response = loadIcon(request);
+
+            if (response != null) {
+                processIcon(request, response);
+                executeCallback(request, response);
+
+                logResponse(response);
+
+                return response;
+            }
+        } catch (InterruptedException e) {
+            Log.d(LOGTAG, "IconTask was interrupted", e);
+
+            // Clear interrupt thread.
+            Thread.interrupted();
+        } catch (Throwable e) {
+            handleException(e);
+        }
+
+        return null;
+    }
+
+    /**
+     * Check if this thread was interrupted (e.g. this task was cancelled). Throws an InterruptedException
+     * to stop executing the task in this case.
+     */
+    private void ensureNotInterrupted() throws InterruptedException {
+        if (Thread.currentThread().isInterrupted()) {
+            throw new InterruptedException("Task has been cancelled");
+        }
+    }
+
+    private void executeCallback(IconRequest request, final IconResponse response) {
+        final IconCallback callback = request.getCallback();
+
+        if (callback != null) {
+            if (request.shouldRunOnBackgroundThread()) {
+                ThreadUtils.postToBackgroundThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        callback.onIconResponse(response);
+                    }
+                });
+            } else {
+                ThreadUtils.postToUiThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        callback.onIconResponse(response);
+                    }
+                });
+            }
+        }
+    }
+
+    private void prepareRequest(IconRequest request) throws InterruptedException {
+        for (Preparer preparer : preparers) {
+            ensureNotInterrupted();
+
+            preparer.prepare(request);
+
+            logPreparer(request, preparer);
+        }
+    }
+
+    private IconResponse loadIcon(IconRequest request) throws InterruptedException {
+        while (request.hasIconDescriptors()) {
+            for (IconLoader loader : loaders) {
+                ensureNotInterrupted();
+
+                IconResponse response = loader.load(request);
+
+                logLoader(request, loader, response);
+
+                if (response != null) {
+                    return response;
+                }
+            }
+
+            request.moveToNextIcon();
+        }
+
+        return generator.load(request);
+    }
+
+    private void processIcon(IconRequest request, IconResponse response) throws InterruptedException {
+        for (Processor processor : processors) {
+            ensureNotInterrupted();
+
+            processor.process(request, response);
+
+            logProcessor(processor);
+        }
+    }
+
+    private void handleException(final Throwable t) {
+        if (AppConstants.NIGHTLY_BUILD) {
+            // We want to be aware of problems: Let's re-throw the exception on the main thread to
+            // force an app crash. However we only do this in Nightly builds. Every other build
+            // (especially release builds) should just carry on and log the error.
+            ThreadUtils.postToUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    throw new RuntimeException("Icon task thread crashed", t);
+                }
+            });
+        } else {
+            Log.e(LOGTAG, "Icon task crashed", t);
+        }
+    }
+
+    private boolean shouldLog() {
+        // Do not log anything if debugging is disabled and never log anything in a non-nightly build.
+        return DEBUG && AppConstants.NIGHTLY_BUILD;
+    }
+
+    private void logPreparer(IconRequest request, Preparer preparer) {
+        if (!shouldLog()) {
+            return;
+        }
+
+        Log.d(LOGTAG, String.format("  PREPARE %s" + " (%s)",
+                preparer.getClass().getSimpleName(),
+                request.getIconCount()));
+    }
+
+    private void logLoader(IconRequest request, IconLoader loader, IconResponse response) {
+        if (!shouldLog()) {
+            return;
+        }
+
+        Log.d(LOGTAG, String.format("  LOAD [%s] %s : %s",
+                response != null ? "X" : " ",
+                loader.getClass().getSimpleName(),
+                request.getBestIcon().getUrl()));
+    }
+
+    private void logProcessor(Processor processor) {
+        if (!shouldLog()) {
+            return;
+        }
+
+        Log.d(LOGTAG, "  PROCESS " + processor.getClass().getSimpleName());
+    }
+
+    private void logResponse(IconResponse response) {
+        if (!shouldLog()) {
+            return;
+        }
+
+        final Bitmap bitmap = response.getBitmap();
+
+        Log.d(LOGTAG, String.format("=> ICON: %sx%s", bitmap.getWidth(), bitmap.getHeight()));
+    }
+
+    private void logRequest(IconRequest request) {
+        if (!shouldLog()) {
+            return;
+        }
+
+        Log.d(LOGTAG, String.format("REQUEST (%s) %s",
+                request.getIconCount(),
+                request.getPageUrl()));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java
@@ -0,0 +1,35 @@
+/* -*- 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.icons;
+
+import android.content.Context;
+import android.support.annotation.CheckResult;
+
+/**
+ * Entry point for loading icons for websites (just high quality icons, can be favicons or
+ * touch icons).
+ *
+ * The API is loosely inspired by Picasso's builder.
+ *
+ * Example:
+ *
+ *     Icons.with(context)
+ *         .pageUrl(pageURL)
+ *         .skipNetwork()
+ *         .privileged(true)
+ *         .icon(IconDescriptor.createGenericIcon(url))
+ *         .build()
+ *         .execute(callback);
+ */
+public abstract class Icons {
+    /**
+     * Create a new request for loading a website icon.
+     */
+    @CheckResult
+    public static IconRequestBuilder with(Context context) {
+        return new IconRequestBuilder(context);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java
@@ -0,0 +1,140 @@
+/* -*- 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.icons;
+
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.util.HashSet;
+
+/**
+ * Helper methods for icon related tasks.
+ */
+public class IconsHelper {
+    private static final String LOGTAG = "Gecko/IconsHelper";
+
+    // Mime types of things we are capable of decoding.
+    private static final HashSet<String> sDecodableMimeTypes = new HashSet<>();
+
+    // Mime types of things we are both capable of decoding and are container formats (May contain
+    // multiple different sizes of image)
+    private static final HashSet<String> sContainerMimeTypes = new HashSet<>();
+
+    static {
+        // MIME types extracted from http://filext.com - ostensibly all in-use mime types for the
+        // corresponding formats.
+        // ICO
+        sContainerMimeTypes.add("image/vnd.microsoft.icon");
+        sContainerMimeTypes.add("image/ico");
+        sContainerMimeTypes.add("image/icon");
+        sContainerMimeTypes.add("image/x-icon");
+        sContainerMimeTypes.add("text/ico");
+        sContainerMimeTypes.add("application/ico");
+
+        // Add supported container types to the set of supported types.
+        sDecodableMimeTypes.addAll(sContainerMimeTypes);
+
+        // PNG
+        sDecodableMimeTypes.add("image/png");
+        sDecodableMimeTypes.add("application/png");
+        sDecodableMimeTypes.add("application/x-png");
+
+        // GIF
+        sDecodableMimeTypes.add("image/gif");
+
+        // JPEG
+        sDecodableMimeTypes.add("image/jpeg");
+        sDecodableMimeTypes.add("image/jpg");
+        sDecodableMimeTypes.add("image/pipeg");
+        sDecodableMimeTypes.add("image/vnd.swiftview-jpeg");
+        sDecodableMimeTypes.add("application/jpg");
+        sDecodableMimeTypes.add("application/x-jpg");
+
+        // BMP
+        sDecodableMimeTypes.add("application/bmp");
+        sDecodableMimeTypes.add("application/x-bmp");
+        sDecodableMimeTypes.add("application/x-win-bitmap");
+        sDecodableMimeTypes.add("image/bmp");
+        sDecodableMimeTypes.add("image/x-bmp");
+        sDecodableMimeTypes.add("image/x-bitmap");
+        sDecodableMimeTypes.add("image/x-xbitmap");
+        sDecodableMimeTypes.add("image/x-win-bitmap");
+        sDecodableMimeTypes.add("image/x-windows-bitmap");
+        sDecodableMimeTypes.add("image/x-ms-bitmap");
+        sDecodableMimeTypes.add("image/x-ms-bmp");
+        sDecodableMimeTypes.add("image/ms-bmp");
+    }
+
+    /**
+     * Helper method to getIcon the default Favicon URL for a given pageURL. Generally: somewhere.com/favicon.ico
+     *
+     * @param pageURL Page URL for which a default Favicon URL is requested
+     * @return The default Favicon URL or null if no default URL could be guessed.
+     */
+    @Nullable
+    public static String guessDefaultFaviconURL(String pageURL) {
+        if (TextUtils.isEmpty(pageURL)) {
+            return null;
+        }
+
+        // Special-casing for about: pages. The favicon for about:pages which don't provide a link tag
+        // is bundled in the database, keyed only by page URL, hence the need to return the page URL
+        // here. If the database ever migrates to stop being silly in this way, this can plausibly
+        // be removed.
+        if (AboutPages.isAboutPage(pageURL) || pageURL.startsWith("jar:")) {
+            return pageURL;
+        }
+
+        if (!StringUtils.isHttpOrHttps(pageURL)) {
+            // Guessing a default URL only makes sense for http(s) URLs.
+            return null;
+        }
+
+        try {
+            // Fall back to trying "someScheme:someDomain.someExtension/favicon.ico".
+            Uri uri = Uri.parse(pageURL);
+            if (uri.getAuthority().isEmpty()) {
+                return null;
+            }
+
+            return uri.buildUpon()
+                    .path("favicon.ico")
+                    .clearQuery()
+                    .fragment("")
+                    .build()
+                    .toString();
+        } catch (Exception e) {
+            Log.d(LOGTAG, "Exception getting default favicon URL");
+            return null;
+        }
+    }
+
+    /**
+     * Helper function to determine if the provided mime type is that of a format that can contain
+     * multiple image types. At time of writing, the only such type is ICO.
+     * @param mimeType Mime type to check.
+     * @return true if the given mime type is a container type, false otherwise.
+     */
+    public static boolean isContainerType(@NonNull String mimeType) {
+        return sContainerMimeTypes.contains(mimeType);
+    }
+
+    /**
+     * Helper function to determine if we can decode a particular mime type.
+     *
+     * @param imgType Mime type to check for decodability.
+     * @return false if the given mime type is certainly not decodable, true if it might be.
+     */
+    public static boolean canDecodeType(@NonNull String imgType) {
+        return sDecodableMimeTypes.contains(imgType);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java
@@ -0,0 +1,96 @@
+/* -*- 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.icons.loader;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.favicons.decoders.FaviconDecoder;
+import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Loader for loading icons from a content provider. This loader was primarily written to load icons
+ * from the partner bookmarks provider. However it can load icons from arbitrary content providers
+ * as long as they return a cursor with a "favicon" or "touchicon" column (blob).
+ */
+public class ContentProviderLoader implements IconLoader {
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipDisk()) {
+            // If we should not load data from disk then we do not load from content providers either.
+            return null;
+        }
+
+        final String iconUrl = request.getBestIcon().getUrl();
+        final Context context = request.getContext();
+        final int targetSize = request.getTargetSize();
+
+        if (TextUtils.isEmpty(iconUrl) || !iconUrl.startsWith("content://")) {
+            return null;
+        }
+
+        Cursor cursor = context.getContentResolver().query(
+                Uri.parse(iconUrl),
+                new String[] {
+                        PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON,
+                        PartnerBookmarksProviderProxy.PartnerContract.FAVICON,
+                },
+                null,
+                null,
+                null
+        );
+
+        if (cursor == null) {
+            return null;
+        }
+
+        try {
+            if (!cursor.moveToFirst()) {
+                return null;
+            }
+
+            // Try the touch icon first. It has a higher resolution usually.
+            Bitmap icon = decodeFromCursor(cursor, PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON, targetSize);
+            if (icon != null) {
+                return IconResponse.create(icon);
+            }
+
+            icon = decodeFromCursor(cursor, PartnerBookmarksProviderProxy.PartnerContract.FAVICON, targetSize);
+            if (icon != null) {
+                return IconResponse.create(icon);
+            }
+        } finally {
+            cursor.close();
+        }
+
+        return null;
+    }
+
+    private Bitmap decodeFromCursor(Cursor cursor, String column, int targetWidthAndHeight) {
+        final int index = cursor.getColumnIndex(column);
+        if (index == -1) {
+            return null;
+        }
+
+        if (cursor.isNull(index)) {
+            return null;
+        }
+
+        final byte[] data = cursor.getBlob(index);
+        LoadFaviconResult result = FaviconDecoder.decodeFavicon(data, 0, data.length);
+        if (result == null) {
+            return null;
+        }
+
+        return result.getBestBitmap(targetWidthAndHeight);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java
@@ -0,0 +1,36 @@
+/* -*- 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.icons.loader;
+
+import org.mozilla.gecko.favicons.decoders.FaviconDecoder;
+import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Loader for loading icons from a data URI. This loader will try to decode any data with an
+ * "image/*" MIME type.
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
+ */
+public class DataUriLoader implements IconLoader {
+    @Override
+    public IconResponse load(IconRequest request) {
+        final String iconUrl = request.getBestIcon().getUrl();
+
+        if (!iconUrl.startsWith("data:image/")) {
+            return null;
+        }
+
+        LoadFaviconResult loadFaviconResult = FaviconDecoder.decodeDataURI(iconUrl);
+        if (loadFaviconResult == null) {
+            return null;
+        }
+
+        return IconResponse.create(
+                loadFaviconResult.getBestBitmap(request.getTargetSize()));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java
@@ -0,0 +1,27 @@
+/* -*- 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.icons.loader;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+
+/**
+ * Loader implementation for loading icons from the disk cache (Implemented by DiskStorage).
+ */
+public class DiskLoader implements IconLoader {
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipDisk()) {
+            return null;
+        }
+
+        final DiskStorage storage = DiskStorage.get(request.getContext());
+        final String iconUrl = request.getBestIcon().getUrl();
+
+        return storage.getIcon(iconUrl);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java
@@ -0,0 +1,214 @@
+/* -*- 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.icons.loader;
+
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.favicons.decoders.FaviconDecoder;
+import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.ProxySelector;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashSet;
+
+/**
+ * This loader implementation downloads icons from http(s) URLs.
+ */
+public class IconDownloader implements IconLoader {
+    private static final String LOGTAG = "Gecko/Downloader";
+
+    /**
+     * The maximum number of http redirects (3xx) until we give up.
+     */
+    private static final int MAX_REDIRECTS_TO_FOLLOW = 5;
+
+    /**
+     * The default size of the buffer to use for downloading Favicons in the event no size is given
+     * by the server. */
+    private static final int DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000;
+
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipNetwork()) {
+            return null;
+        }
+
+        final String iconUrl = request.getBestIcon().getUrl();
+
+        if (!StringUtils.isHttpOrHttps(iconUrl)) {
+            return null;
+        }
+
+        try {
+            LoadFaviconResult result = downloadAndDecodeImage(iconUrl);
+            if (result == null) {
+                return null;
+            }
+
+            return IconResponse.createFromNetwork(
+                    result.getBestBitmap(request.getTargetSize()),
+                    iconUrl);
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Error reading favicon", e);
+        } catch (OutOfMemoryError e) {
+            Log.e(LOGTAG, "Insufficient memory to process favicon");
+        }
+
+        return null;
+    }
+
+    /**
+     * Download the Favicon from the given URL and pass it to the decoder function.
+     *
+     * @param targetFaviconURL URL of the favicon to download.
+     * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or
+     *         null if no or corrupt data was received.
+     * @throws IOException If attempts to fully read the stream result in such an exception, such as
+     *                     in the event of a transient connection failure.
+     * @throws URISyntaxException If the underlying call to tryDownload retries and raises such an
+     *                            exception trying a fallback URL.
+     */
+    @VisibleForTesting
+    LoadFaviconResult downloadAndDecodeImage(String targetFaviconURL) throws IOException, URISyntaxException {
+        // Try the URL we were given.
+        HttpURLConnection connection = tryDownload(targetFaviconURL);
+        if (connection == null) {
+            return null;
+        }
+
+        InputStream stream = null;
+
+        // Decode the image from the fetched response.
+        try {
+            stream = connection.getInputStream();
+            return decodeImageFromResponse(connection.getInputStream(), connection.getHeaderFieldInt("Content-Length", -1));
+        } finally {
+            // Close the stream and free related resources.
+            IOUtils.safeStreamClose(stream);
+            connection.disconnect();
+        }
+    }
+
+    /**
+     * Helper method for trying the download request to grab a Favicon.
+     *
+     * @param faviconURI URL of Favicon to try and download
+     * @return The HttpResponse containing the downloaded Favicon if successful, null otherwise.
+     */
+    private HttpURLConnection tryDownload(String faviconURI) throws URISyntaxException, IOException {
+        HashSet<String> visitedLinkSet = new HashSet<>();
+        visitedLinkSet.add(faviconURI);
+        return tryDownloadRecurse(faviconURI, visitedLinkSet);
+    }
+
+    /**
+     * Try to download from the favicon URL and recursively follow redirects.
+     */
+    private HttpURLConnection tryDownloadRecurse(String faviconURI, HashSet<String> visited) throws URISyntaxException, IOException {
+        if (visited.size() == MAX_REDIRECTS_TO_FOLLOW) {
+            return null;
+        }
+
+        HttpURLConnection connection = connectTo(faviconURI);
+
+        // Was the response a failure?
+        int status = connection.getResponseCode();
+
+        // Handle HTTP status codes requesting a redirect.
+        if (status >= 300 && status < 400) {
+            final String newURI = connection.getHeaderField("Location");
+
+            // Handle mad web servers.
+            try {
+                if (newURI == null || newURI.equals(faviconURI)) {
+                    return null;
+                }
+
+                if (visited.contains(newURI)) {
+                    // Already been redirected here - abort.
+                    return null;
+                }
+
+                visited.add(newURI);
+            } finally {
+                connection.disconnect();
+            }
+
+            return tryDownloadRecurse(newURI, visited);
+        }
+
+        if (status >= 400) {
+            // Client or Server error. Let's not retry loading from this URL again for some time.
+            FailureCache.get().rememberFailure(faviconURI);
+
+            connection.disconnect();
+            return null;
+        }
+
+        return connection;
+    }
+
+    @VisibleForTesting
+    HttpURLConnection connectTo(String faviconURI) throws URISyntaxException, IOException {
+        HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(
+                new URI(faviconURI));
+
+        connection.setRequestProperty("User-Agent", GeckoAppShell.getGeckoInterface().getDefaultUAString());
+
+        // We implemented or own way of following redirects back when this code was using HttpClient.
+        // Nowadays we should let HttpUrlConnection do the work - assuming that it doesn't follow
+        // redirects in loops forever.
+        connection.setInstanceFollowRedirects(false);
+
+        connection.connect();
+
+        return connection;
+    }
+
+    /**
+     * Copies the favicon stream to a buffer and decodes downloaded content into bitmaps using the
+     * FaviconDecoder.
+     *
+     * @param stream to decode
+     * @param contentLength as reported by the server (or -1)
+     * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or
+     *         null if no or corrupt data were received.
+     * @throws IOException If attempts to fully read the stream result in such an exception, such as
+     *                     in the event of a transient connection failure.
+     */
+    private LoadFaviconResult decodeImageFromResponse(InputStream stream, int contentLength) throws IOException {
+        // This may not be provided, but if it is, it's useful.
+        int bufferSize;
+        if (contentLength > 0) {
+            // The size was reported and sane, so let's use that.
+            // Integer overflow should not be a problem for Favicon sizes...
+            bufferSize = contentLength + 1;
+        } else {
+            // No declared size, so guess and reallocate later if it turns out to be too small.
+            bufferSize = DEFAULT_FAVICON_BUFFER_SIZE_BYTES;
+        }
+
+        // Read the InputStream into a byte[].
+        IOUtils.ConsumedInputStream result = IOUtils.readFully(stream, bufferSize);
+        if (result == null) {
+            return null;
+        }
+
+        // Having downloaded the image, decode it.
+        return FaviconDecoder.decodeFavicon(result.getData(), 0, result.consumedLength);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java
@@ -0,0 +1,30 @@
+/* -*- 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.icons.loader;
+
+import org.mozilla.gecko.favicons.FaviconGenerator;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * This loader will generate an icon in case no icon could be loaded. In order to do so this needs
+ * to be the last loader that will be tried.
+ */
+public class IconGenerator implements IconLoader {
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.getIconCount() > 1) {
+            // There are still other icons to try. We will only generate an icon if there's only one
+            // icon left and all previous loaders have failed (assuming this is the last one).
+            return null;
+        }
+
+        final FaviconGenerator.IconWithColor iconWithColor = FaviconGenerator.generate(
+                request.getContext(), request.getPageUrl());
+
+        return IconResponse.createGenerated(iconWithColor.bitmap, iconWithColor.color);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java
@@ -0,0 +1,23 @@
+/* -*- 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.icons.loader;
+
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Generic interface for classes that can load icons.
+ */
+public interface IconLoader {
+    /**
+     * Loads the icon for this request or returns null if this loader can't load an icon for this
+     * request or just failed this time.
+     */
+    @Nullable
+    IconResponse load(IconRequest request);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java
@@ -0,0 +1,45 @@
+/* -*- 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.icons.loader;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+/**
+ * Loader implementation for loading icons from the omni.ja (jar:jar: URLs).
+ *
+ * https://developer.mozilla.org/en-US/docs/Mozilla/About_omni.ja_(formerly_omni.jar)
+ */
+public class JarLoader implements IconLoader {
+    private static final String LOGTAG = "Gecko/JarLoader";
+
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipDisk()) {
+            return null;
+        }
+
+        final String iconUrl = request.getBestIcon().getUrl();
+
+        if (!iconUrl.startsWith("jar:jar:")) {
+            return null;
+        }
+
+        try {
+            final Context context = request.getContext();
+            return IconResponse.create(
+                    GeckoJarReader.getBitmap(context, context.getResources(), iconUrl));
+        } catch (Exception e) {
+            // Just about anything could happen here.
+            Log.w(LOGTAG, "Error fetching favicon from JAR.", e);
+            return null;
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java
@@ -0,0 +1,61 @@
+/* -*- 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.icons.loader;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * This legacy loader loads icons from the abandoned database storage. This loader should only exist
+ * for a couple of releases and be removed afterwards.
+ *
+ * When updating to an app version with the new loaders our initial storage won't have any data so
+ * we need to continue loading from the database storage until the new storage has a good set of data.
+ */
+public class LegacyLoader implements IconLoader {
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipDisk()) {
+            return null;
+        }
+
+        final Bitmap bitmap = loadBitmapFromDatabase(request);
+
+        if (bitmap == null) {
+            return null;
+        }
+
+        return IconResponse.create(bitmap);
+    }
+
+    /* package-private */ Bitmap loadBitmapFromDatabase(IconRequest request) {
+        final Context context = request.getContext();
+        final ContentResolver contentResolver = context.getContentResolver();
+        final BrowserDB db = GeckoProfile.get(request.getContext()).getDB();
+
+        // We ask the database for the favicon URL and ignore the icon URL in the request object:
+        // As we are not updating the database anymore the icon might be stored under a different URL.
+        final String legacyFaviconUrl = db.getFaviconURLFromPageURL(contentResolver, request.getPageUrl());
+        if (legacyFaviconUrl == null) {
+            // No URL -> Nothing to load.
+            return null;
+        }
+
+        final LoadFaviconResult result = db.getFaviconForUrl(context.getContentResolver(), legacyFaviconUrl);
+        if (result == null) {
+            return null;
+        }
+
+        return result.getBestBitmap(request.getTargetSize());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java
@@ -0,0 +1,31 @@
+/* -*- 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.icons.loader;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+/**
+ * Loader implementation for loading icons from an in-memory cached (Implemented by MemoryStorage).
+ */
+public class MemoryLoader implements IconLoader {
+    private final MemoryStorage storage;
+
+    public MemoryLoader() {
+        storage = MemoryStorage.get();
+    }
+
+    @Override
+    public IconResponse load(IconRequest request) {
+        if (request.shouldSkipMemory()) {
+            return null;
+        }
+
+        final String iconUrl = request.getBestIcon().getUrl();
+        return storage.getIcon(iconUrl);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java
@@ -0,0 +1,39 @@
+/* -*- 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.icons.preparation;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Preparer implementation for adding the omni.ja URL for internal about: pages.
+ */
+public class AboutPagesPreparer implements Preparer {
+    private Set<String> aboutUrls;
+
+    public AboutPagesPreparer() {
+        aboutUrls = new HashSet<>();
+
+        Collections.addAll(aboutUrls, AboutPages.DEFAULT_ICON_PAGES);
+    }
+
+    @Override
+    public void prepare(IconRequest request) {
+        if (aboutUrls.contains(request.getPageUrl())) {
+            final String iconUrl = GeckoJarReader.getJarURL(request.getContext(), "chrome/chrome/content/branding/favicon64.png");
+
+            request.modify()
+                    .icon(IconDescriptor.createLookupIcon(iconUrl))
+                    .deferBuild();
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java
@@ -0,0 +1,39 @@
+/* -*- 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.icons.preparation;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconsHelper;
+import org.mozilla.gecko.util.StringUtils;
+
+/**
+ * Preparer to add the "default/guessed" favicon URL (domain/favicon.ico) to the list of URLs to
+ * try loading the favicon from.
+ *
+ * The default URL will be added with a very low priority so that we will only try to load from this
+ * URL if all other options failed.
+ */
+public class AddDefaultIconUrl implements Preparer {
+    @Override
+    public void prepare(IconRequest request) {
+        if (!StringUtils.isHttpOrHttps(request.getPageUrl())) {
+            return;
+        }
+
+        final String defaultFaviconUrl = IconsHelper.guessDefaultFaviconURL(request.getPageUrl());
+        if (TextUtils.isEmpty(defaultFaviconUrl)) {
+            // We couldn't generate a default favicon URL for this URL. Nothing to do here.
+            return;
+        }
+
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(defaultFaviconUrl))
+                .deferBuild();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java
@@ -0,0 +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.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.storage.FailureCache;
+
+import java.util.Iterator;
+
+public class FilterKnownFailureUrls implements Preparer {
+    @Override
+    public void prepare(IconRequest request) {
+        final FailureCache failureCache = FailureCache.get();
+        final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+        while (iterator.hasNext()) {
+            final IconDescriptor descriptor = iterator.next();
+
+            if (failureCache.isKnownFailure(descriptor.getUrl())) {
+                // Loading from this URL has failed in the past. Do not try again.
+                iterator.remove();
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java
@@ -0,0 +1,39 @@
+/* -*- 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.icons.preparation;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconsHelper;
+
+import java.util.Iterator;
+
+/**
+ * Preparer implementation to filter unknown MIME types to avoid loading images that we cannot decode.
+ */
+public class FilterMimeTypes implements Preparer {
+    @Override
+    public void prepare(IconRequest request) {
+        final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+        while (iterator.hasNext()) {
+            final IconDescriptor descriptor = iterator.next();
+            final String mimeType = descriptor.getMimeType();
+
+            if (TextUtils.isEmpty(mimeType)) {
+                // We do not have a MIME type for this icon, so we cannot know in advance if we are able
+                // to decode it. Let's just continue.
+                return;
+            }
+
+            if (!IconsHelper.canDecodeType(mimeType)) {
+                iterator.remove();
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java
@@ -0,0 +1,30 @@
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.util.Iterator;
+
+/**
+ * Filter non http/https URLs if the request is not from privileged code.
+ */
+public class FilterPrivilegedUrls implements Preparer {
+    @Override
+    public void prepare(IconRequest request) {
+        if (request.isPrivileged()) {
+            // This request is privileged. No need to filter anything.
+            return;
+        }
+
+        final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+        while (iterator.hasNext()) {
+            IconDescriptor descriptor = iterator.next();
+
+            if (!StringUtils.isHttpOrHttps(descriptor.getUrl())) {
+                iterator.remove();
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java
@@ -0,0 +1,56 @@
+/* -*- 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.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+/**
+ * Preparer implementation to lookup the icon URL for the page URL in the request. This class tries
+ * to locate the icon URL by looking through previously stored mappings on disk and in memory.
+ */
+public class LookupIconUrl implements Preparer {
+    @Override
+    public void prepare(IconRequest request) {
+        if (lookupFromMemory(request)) {
+            return;
+        }
+
+        lookupFromDisk(request);
+    }
+
+    private boolean lookupFromMemory(IconRequest request) {
+        final String iconUrl = MemoryStorage.get()
+                .getMapping(request.getPageUrl());
+
+        if (iconUrl != null) {
+            request.modify()
+                    .icon(IconDescriptor.createLookupIcon(iconUrl))
+                    .deferBuild();
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private boolean lookupFromDisk(IconRequest request) {
+        final String iconUrl = DiskStorage.get(request.getContext())
+                .getMapping(request.getPageUrl());
+
+        if (iconUrl != null) {
+            request.modify()
+                    .icon(IconDescriptor.createLookupIcon(iconUrl))
+                    .deferBuild();
+
+            return true;
+        }
+
+        return false;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java
@@ -0,0 +1,19 @@
+/* -*- 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.icons.preparation;
+
+import org.mozilla.gecko.icons.IconRequest;
+
+/**
+ * Generic interface for a class "preparing" a request before we try to load icons. A class
+ * implementing this interface can modify the request (e.g. filter or add icon URLs).
+ */
+public interface Preparer {
+    /**
+     * Inspects or modifies the request before any icon is loaded.
+     */
+    void prepare(IconRequest request);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java
@@ -0,0 +1,25 @@
+/* -*- 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.icons.processing;
+
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Processor implementation to extract the dominant color from the icon and attach it to the icon
+ * response object.
+ */
+public class ColorProcessor implements Processor {
+    @Override
+    public void process(IconRequest request, IconResponse response) {
+        if (response.hasColor()) {
+            return;
+        }
+
+        response.updateColor(BitmapUtils.getDominantColor(response.getBitmap()));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java
@@ -0,0 +1,36 @@
+package org.mozilla.gecko.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.util.StringUtils;
+
+public class DiskProcessor implements Processor {
+    @Override
+    public void process(IconRequest request, IconResponse response) {
+        if (request.shouldSkipDisk()) {
+            return;
+        }
+
+        if (!response.hasUrl() || !StringUtils.isHttpOrHttps(response.getUrl())) {
+            // If the response does not contain an URL from which the icon was loaded or if this is
+            // not a http(s) URL then we cannot store this or do not need to (because it's already
+            // stored somewhere else, like for URLs pointing inside the omni.ja).
+            return;
+        }
+
+        final DiskStorage storage = DiskStorage.get(request.getContext());
+
+        if (response.isFromNetwork()) {
+            // The icon has been loaded from the network. Store it on the disk now.
+            storage.putIcon(response);
+        }
+
+        if (response.isFromMemory() || response.isFromDisk() || response.isFromNetwork()) {
+            // Remember mapping between page URL and storage URL. Even when this icon has been loaded
+            // from memory or disk this does not mean that we stored this mapping already: We could
+            // have loaded this icon for a different page URL previously.
+            storage.putMapping(request, response.getUrl());
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java
@@ -0,0 +1,38 @@
+/* -*- 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.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+public class MemoryProcessor implements Processor {
+    private final MemoryStorage storage;
+
+    public MemoryProcessor() {
+        storage = MemoryStorage.get();
+    }
+
+    @Override
+    public void process(IconRequest request, IconResponse response) {
+        if (request.shouldSkipMemory() || request.getIconCount() == 0 || response.isGenerated()) {
+            // Do not cache this icon in memory if we should skip the memory cache or if this icon
+            // has been generated. We can re-generate it if needed.
+            return;
+        }
+
+        final String iconUrl = request.getBestIcon().getUrl();
+
+        if (iconUrl.startsWith("data:image/")) {
+            // The image data is encoded in the URL. It doesn't make sense to store the URL and the
+            // bitmap in cache.
+            return;
+        }
+
+        storage.putMapping(request, iconUrl);
+        storage.putIcon(iconUrl, response);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java
@@ -0,0 +1,21 @@
+/* -*- 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.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Generic interface for a class that processes a response object after an icon has been loaded and
+ * decoded. A class implementing this interface can attach additional data to the response or modify
+ * the bitmap (e.g. resizing).
+ */
+public interface Processor {
+    /**
+     * Process a response object containing an icon loaded for this request.
+     */
+    void process(IconRequest request, IconResponse response);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java
@@ -0,0 +1,62 @@
+/* -*- 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.icons.processing;
+
+import android.graphics.Bitmap;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Processor implementation for resizing the loaded icon based on the target size.
+ */
+public class ResizingProcessor implements Processor {
+    @Override
+    public void process(IconRequest request, IconResponse response) {
+        final Bitmap originalBitmap = response.getBitmap();
+        final int size = originalBitmap.getWidth();
+
+        final int targetSize = request.getTargetSize();
+
+        if (size == targetSize) {
+            // The bitmap has exactly the size we are looking for.
+            return;
+        }
+
+        final Bitmap resizedBitmap;
+
+        if (size > targetSize) {
+            resizedBitmap = resize(originalBitmap, targetSize);
+        } else {
+            // Our largest primary is smaller than the desired size. Upscale by a maximum of 2x.
+            // 'largestSize' now reflects the maximum size we can upscale to.
+            final int largestSize = size * 2;
+
+            if (largestSize > targetSize) {
+                // Perfect! We can upscale by less than 2x and reach the needed size. Do it.
+                resizedBitmap = resize(originalBitmap, targetSize);
+            } else {
+                // We don't have enough information to make the target size look non terrible. Best effort:
+                resizedBitmap = resize(originalBitmap, largestSize);
+            }
+        }
+
+        response.updateBitmap(resizedBitmap);
+
+        originalBitmap.recycle();
+    }
+
+    @VisibleForTesting Bitmap resize(Bitmap bitmap, int targetSize) {
+        try {
+            return  Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true);
+        } catch (OutOfMemoryError error) {
+            // There's not enough memory to create a resized copy of the bitmap in memory. Let's just
+            // use what we have.
+            return bitmap;
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java
@@ -0,0 +1,290 @@
+/* -*- 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.icons.storage;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.support.annotation.CheckResult;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import com.jakewharton.disklrucache.DiskLruCache;
+
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * Least Recently Used (LRU) disk cache for icons and the mappings from page URLs to icon URLs.
+ */
+public class DiskStorage {
+    private static final String LOGTAG = "Gecko/DiskStorage";
+
+    /**
+     * Maximum size (in bytes) of the cache. This cache is located in the cache directory of the
+     * application and can be cleared by the user.
+     */
+    private static final int DISK_CACHE_SIZE = 50 * 1024 * 1024;
+
+    /**
+     * Version of the cache. Updating the version will invalidate all existing items.
+     */
+    private static final int CACHE_VERSION = 1;
+
+    private static final String KEY_PREFIX_ICON = "icon:";
+    private static final String KEY_PREFIX_MAPPING = "mapping:";
+
+    private static DiskStorage instance;
+
+    public static DiskStorage get(Context context) {
+        if (instance == null) {
+            instance = new DiskStorage(context);
+        }
+
+        return instance;
+    }
+
+    private Context context;
+    private DiskLruCache cache;
+
+    private DiskStorage(Context context) {
+        this.context = context.getApplicationContext();
+    }
+
+    @CheckResult
+    private synchronized DiskLruCache ensureCacheIsReady() throws IOException {
+        if (cache == null) {
+            cache = DiskLruCache.open(
+                    new File(context.getCacheDir(), "icons"),
+                    CACHE_VERSION,
+                    1,
+                    DISK_CACHE_SIZE);
+        }
+
+        return cache;
+    }
+
+    /**
+     * Store a mapping from page URL to icon URL in the cache.
+     */
+    public void putMapping(IconRequest request, String iconUrl) {
+        putMapping(request.getPageUrl(), iconUrl);
+    }
+
+    /**
+     * Store a mapping from page URL to icon URL in the cache.
+     */
+    public void putMapping(String pageUrl, String iconUrl) {
+        DiskLruCache.Editor editor = null;
+
+        try {
+            final DiskLruCache cache = ensureCacheIsReady();
+
+            final String key = createKey(KEY_PREFIX_MAPPING, pageUrl);
+            if (key == null) {
+                return;
+            }
+
+            editor = cache.edit(key);
+            if (editor == null) {
+                return;
+            }
+
+            editor.set(0, iconUrl);
+            editor.commit();
+        } catch (IOException e) {
+            Log.w(LOGTAG, "IOException while accessing disk cache", e);
+
+            abortSilently(editor);
+        }
+    }
+
+    /**
+     * Store an icon in the cache (uses the icon URL as key).
+     */
+    public void putIcon(IconResponse response) {
+        putIcon(response.getUrl(), response.getBitmap());
+    }
+
+    /**
+     * Store an icon in the cache (uses the icon URL as key).
+     */
+    public void putIcon(String iconUrl, Bitmap bitmap) {
+        OutputStream outputStream = null;
+        DiskLruCache.Editor editor = null;
+
+        try {
+            final DiskLruCache cache = ensureCacheIsReady();
+
+            final String key = createKey(KEY_PREFIX_ICON, iconUrl);
+            if (key == null) {
+                return;
+            }
+
+            editor = cache.edit(key);
+            if (editor == null) {
+                return;
+            }
+
+            outputStream = editor.newOutputStream(0);
+            boolean success = bitmap.compress(Bitmap.CompressFormat.PNG, 100 /* quality; ignored. PNG is lossless */, outputStream);
+
+            outputStream.close();
+
+            if (success) {
+                editor.commit();
+            } else {
+                editor.abort();
+            }
+        } catch (IOException e) {
+            Log.w(LOGTAG, "IOException while accessing disk cache", e);
+
+            abortSilently(editor);
+        } finally {
+            IOUtils.safeStreamClose(outputStream);
+        }
+    }
+
+
+
+    /**
+     * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL.
+     */
+    @Nullable
+    public IconResponse getIcon(String iconUrl) {
+        InputStream inputStream = null;
+
+        try {
+            final DiskLruCache cache = ensureCacheIsReady();
+
+            final String key = createKey(KEY_PREFIX_ICON, iconUrl);
+            if (key == null) {
+                return null;
+            }
+
+            if (cache.isClosed()) {
+                throw new RuntimeException("CLOSED");
+            }
+
+            final DiskLruCache.Snapshot snapshot = cache.get(key);
+            if (snapshot == null) {
+                return null;
+            }
+
+            inputStream = snapshot.getInputStream(0);
+
+            final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+            if (bitmap == null) {
+                return null;
+            }
+
+            return IconResponse.createFromDisk(bitmap, iconUrl);
+        } catch (IOException e) {
+            Log.w(LOGTAG, "IOException while accessing disk cache", e);
+        } finally {
+            IOUtils.safeStreamClose(inputStream);
+        }
+
+        return null;
+    }
+
+    /**
+     * Get the icon URL for this page URL. Returns null if no mapping is in the cache.
+     */
+    @Nullable
+    public String getMapping(String pageUrl) {
+        try {
+            final DiskLruCache cache = ensureCacheIsReady();
+
+            final String key = createKey(KEY_PREFIX_MAPPING, pageUrl);
+            if (key == null) {
+                return null;
+            }
+
+            DiskLruCache.Snapshot snapshot = cache.get(key);
+            if (snapshot == null) {
+                return null;
+            }
+
+            return snapshot.getString(0);
+        } catch (IOException e) {
+            Log.w(LOGTAG, "IOException while accessing disk cache", e);
+        }
+
+        return null;
+    }
+
+    /**
+     * Remove all entries from this cache.
+     */
+    public void evictAll() {
+        try {
+            final DiskLruCache cache = ensureCacheIsReady();
+
+            cache.delete();
+        } catch (IOException e) {
+            Log.w(LOGTAG, "IOException while accessing disk cache", e);
+        }
+    }
+
+    /**
+     * Create a key for this URL using the given prefix.
+     *
+     * The disk cache requires valid file names to be used as key. Therefore we hash the created key
+     * (SHA-256).
+     */
+    @Nullable
+    private String createKey(String prefix, String url) {
+        try {
+            // We use our own crypto implementation to avoid the penalty of loading the java crypto
+            // framework.
+            byte[] ctx = NativeCrypto.sha256init();
+            if (ctx == null) {
+                return null;
+            }
+
+            byte[] data = prefix.getBytes(StandardCharsets.UTF_8);
+            NativeCrypto.sha256update(ctx, data, data.length);
+
+            data = url.getBytes(StandardCharsets.UTF_8);
+            NativeCrypto.sha256update(ctx, data, data.length);
+            return Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
+        } catch (NoClassDefFoundError | ExceptionInInitializerError error) {
+            // We could not load libmozglue.so. Let's use Java's MessageDigest as fallback. We do
+            // this primarily for our unit tests that can't load native libraries. On an device
+            // we will have a lot of other problems if we can't load libmozglue.so
+            try {
+                MessageDigest md = MessageDigest.getInstance("SHA-256");
+                md.update(prefix.getBytes(StandardCharsets.UTF_8));
+                md.update(url.getBytes(StandardCharsets.UTF_8));
+                return Utils.byte2Hex(md.digest());
+            } catch (Exception e) {
+                // Just give up. And let everyone know.
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    private void abortSilently(DiskLruCache.Editor editor) {
+        if (editor != null) {
+            try {
+                editor.abort();
+            } catch (IOException e) {
+                // Ignore
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
@@ -0,0 +1,70 @@
+/* -*- 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.icons.storage;
+
+import android.os.SystemClock;
+import android.support.annotation.VisibleForTesting;
+import android.util.LruCache;
+
+/**
+ * In-memory cache to remember URLs from which loading icons has failed recently.
+ */
+public class FailureCache {
+    /**
+     * Retry loading failed icons after 4 hours.
+     */
+    private static final long FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 60 * 4;
+
+    private static final int MAX_ENTRIES = 25;
+
+    private static FailureCache instance;
+
+    public static synchronized FailureCache get() {
+        if (instance == null) {
+            instance = new FailureCache();
+        }
+
+        return instance;
+    }
+
+    private final LruCache<String, Long> cache;
+
+    private FailureCache() {
+        cache = new LruCache<>(MAX_ENTRIES);
+    }
+
+    /**
+     * Remember this icon URL after loading from it (over the network) has failed.
+     */
+    public void rememberFailure(String iconUrl) {
+        cache.put(iconUrl, SystemClock.elapsedRealtime());
+    }
+
+    /**
+     * Has loading from this URL failed previously and recently?
+     */
+    public boolean isKnownFailure(String iconUrl) {
+        synchronized (cache) {
+            final Long failedAt = cache.get(iconUrl);
+            if (failedAt == null) {
+                return false;
+            }
+
+            if (failedAt + FAILURE_RETRY_MILLISECONDS < SystemClock.elapsedRealtime()) {
+                // The wait time has passed and we can retry loading from this URL.
+                cache.remove(iconUrl);
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    @VisibleForTesting
+    public void evictAll() {
+        cache.evictAll();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java
@@ -0,0 +1,112 @@
+/* -*- 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.icons.storage;
+
+import android.graphics.Bitmap;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.LruCache;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Least Recently Used (LRU) memory cache for icons and the mappings from page URLs to icon URLs.
+ */
+public class MemoryStorage {
+    /**
+     * Maximum number of items in the cache for mapping page URLs to icon URLs.
+     */
+    private static final int MAPPING_CACHE_SIZE = 500;
+
+    private static MemoryStorage instance;
+
+    public static synchronized MemoryStorage get() {
+        if (instance == null) {
+            instance = new MemoryStorage();
+        }
+
+        return instance;
+    }
+
+    /**
+     * Class representing an cached icon. We store the original bitmap and the color in cache only.
+     */
+    private static class CacheEntry {
+        private final Bitmap bitmap;
+        private final int color;
+
+        private CacheEntry(Bitmap bitmap, int color) {
+            this.bitmap = bitmap;
+            this.color = color;
+        }
+    }
+
+    private final LruCache<String, CacheEntry> iconCache; // Guarded by 'this'
+    private final LruCache<String, String> mappingCache; // Guarded by 'this'
+
+    private MemoryStorage() {
+        iconCache = new LruCache<String, CacheEntry>(calculateCacheSize()) {
+            @Override
+            protected int sizeOf(String key, CacheEntry value) {
+                return value.bitmap.getByteCount() / 1024;
+            }
+        };
+
+        mappingCache = new LruCache<>(MAPPING_CACHE_SIZE);
+    }
+
+    private int calculateCacheSize() {
+        // Use a maximum of 1/8 of the available memory for storing cached icons.
+        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
+        return maxMemory / 8;
+    }
+
+    /**
+     * Store a mapping from page URL to icon URL in the cache.
+     */
+    public synchronized void putMapping(IconRequest request, String iconUrl) {
+        mappingCache.put(request.getPageUrl(), iconUrl);
+    }
+
+    /**
+     * Get the icon URL for this page URL. Returns null if no mapping is in the cache.
+     */
+    @Nullable
+    public synchronized String getMapping(String pageUrl) {
+        return mappingCache.get(pageUrl);
+    }
+
+    /**
+     * Store an icon in the cache (uses the icon URL as key).
+     */
+    public synchronized void putIcon(String url, IconResponse response) {
+        final CacheEntry entry = new CacheEntry(response.getBitmap(), response.getColor());
+
+        iconCache.put(url, entry);
+    }
+
+    /**
+     * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL.
+     */
+    @Nullable
+    public synchronized IconResponse getIcon(String iconUrl) {
+        final CacheEntry entry = iconCache.get(iconUrl);
+        if (entry == null) {
+            return null;
+        }
+
+        return IconResponse.createFromMemory(entry.bitmap, iconUrl, entry.color);
+    }
+
+    /**
+     * Remove all entries from this cache.
+     */
+    public synchronized void evictAll() {
+        iconCache.evictAll();
+        mappingCache.evictAll();
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java
@@ -208,17 +208,17 @@ public class FaviconView extends ImageVi
         // Possibly update the display.
         formatImage();
     }
 
     public void showDefaultFavicon(final String pageURL) {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                final Bitmap favicon = FaviconGenerator.generate(getContext(), pageURL);
+                final Bitmap favicon = FaviconGenerator.generate(getContext(), pageURL).bitmap;
 
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
                     public void run() {
                         // We handle the default favicon as any other favicon to avoid the complications of special
                         // casing it. This means that the icon can be scaled both up and down, and the dominant
                         // color box can used if it is enabled in XML attrs.
                         updateAndScaleImage(favicon, DEFAULT_FAVICON_KEY);
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -494,16 +494,50 @@ gbjar.sources += ['java/org/mozilla/geck
     'home/TabMenuStrip.java',
     'home/TabMenuStripLayout.java',
     'home/TopSitesGridItemView.java',
     'home/TopSitesGridView.java',
     'home/TopSitesPanel.java',
     'home/TopSitesThumbnailView.java',
     'home/TwoLinePageRow.java',
     'home/UpdateViewFaviconLoadedListener.java',
+    'icons/IconCallback.java',
+    'icons/IconDescriptor.java',
+    'icons/IconDescriptorComparator.java',
+    'icons/IconRequest.java',
+    'icons/IconRequestBuilder.java',
+    'icons/IconRequestExecutor.java',
+    'icons/IconResponse.java',
+    'icons/Icons.java',
+    'icons/IconsHelper.java',
+    'icons/IconTask.java',
+    'icons/loader/ContentProviderLoader.java',
+    'icons/loader/DataUriLoader.java',
+    'icons/loader/DiskLoader.java',
+    'icons/loader/IconDownloader.java',
+    'icons/loader/IconGenerator.java',
+    'icons/loader/IconLoader.java',
+    'icons/loader/JarLoader.java',
+    'icons/loader/LegacyLoader.java',
+    'icons/loader/MemoryLoader.java',
+    'icons/preparation/AboutPagesPreparer.java',
+    'icons/preparation/AddDefaultIconUrl.java',
+    'icons/preparation/FilterKnownFailureUrls.java',
+    'icons/preparation/FilterMimeTypes.java',
+    'icons/preparation/FilterPrivilegedUrls.java',
+    'icons/preparation/LookupIconUrl.java',
+    'icons/preparation/Preparer.java',
+    'icons/processing/ColorProcessor.java',
+    'icons/processing/DiskProcessor.java',
+    'icons/processing/MemoryProcessor.java',
+    'icons/processing/Processor.java',
+    'icons/processing/ResizingProcessor.java',
+    'icons/storage/DiskStorage.java',
+    'icons/storage/FailureCache.java',
+    'icons/storage/MemoryStorage.java',
     'IntentHelper.java',
     'javaaddons/JavaAddonManager.java',
     'javaaddons/JavaAddonManagerV1.java',
     'LauncherActivity.java',
     'lwt/LightweightTheme.java',
     'lwt/LightweightThemeDrawable.java',
     'mdns/MulticastDNSManager.java',
     'media/AsyncCodec.java',
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/favicons/TestFaviconGenerator.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/favicons/TestFaviconGenerator.java
@@ -74,17 +74,19 @@ public class TestFaviconGenerator {
         Assert.assertEquals(color, FaviconGenerator.pickColor("https://m.facebook.com"));
         Assert.assertEquals(color, FaviconGenerator.pickColor("http://facebook.com"));
         Assert.assertEquals(color, FaviconGenerator.pickColor("http://www.facebook.com"));
         Assert.assertEquals(color, FaviconGenerator.pickColor("http://www.facebook.com/foo/bar/foobar?mobile=1"));
     }
 
     @Test
     public void testGeneratingFavicon() {
-        Bitmap bitmap = FaviconGenerator.generate(RuntimeEnvironment.application, "http://m.facebook.com");
+        final FaviconGenerator.IconWithColor iconWithColor = FaviconGenerator.generate(RuntimeEnvironment.application, "http://m.facebook.com");
+        final Bitmap bitmap = iconWithColor.bitmap;
+
         Assert.assertNotNull(bitmap);
 
         final int size = RuntimeEnvironment.application.getResources().getDimensionPixelSize(R.dimen.favicon_bg);
         Assert.assertEquals(size, bitmap.getWidth());
         Assert.assertEquals(size, bitmap.getHeight());
 
         Assert.assertEquals(Bitmap.Config.ARGB_8888, bitmap.getConfig());
     }
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+@RunWith(TestRunner.class)
+public class TestIconDescriptor {
+    private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+    private static final String MIME_TYPE = "image/png";
+    private static final int ICON_SIZE = 64;
+
+    @Test
+    public void testGenericIconDescriptor() {
+        final IconDescriptor descriptor = IconDescriptor.createGenericIcon(ICON_URL);
+
+        Assert.assertEquals(ICON_URL, descriptor.getUrl());
+        Assert.assertNull(descriptor.getMimeType());
+        Assert.assertEquals(0, descriptor.getSize());
+        Assert.assertEquals(IconDescriptor.TYPE_GENERIC, descriptor.getType());
+    }
+
+    @Test
+    public void testFaviconIconDescriptor() {
+        final IconDescriptor descriptor = IconDescriptor.createFavicon(ICON_URL, ICON_SIZE, MIME_TYPE);
+
+        Assert.assertEquals(ICON_URL, descriptor.getUrl());
+        Assert.assertEquals(MIME_TYPE, descriptor.getMimeType());
+        Assert.assertEquals(ICON_SIZE, descriptor.getSize());
+        Assert.assertEquals(IconDescriptor.TYPE_FAVICON, descriptor.getType());
+    }
+
+    @Test
+    public void testTouchIconDescriptor() {
+        final IconDescriptor descriptor = IconDescriptor.createTouchicon(ICON_URL, ICON_SIZE, MIME_TYPE);
+
+        Assert.assertEquals(ICON_URL, descriptor.getUrl());
+        Assert.assertEquals(MIME_TYPE, descriptor.getMimeType());
+        Assert.assertEquals(ICON_SIZE, descriptor.getSize());
+        Assert.assertEquals(IconDescriptor.TYPE_TOUCHICON, descriptor.getType());
+    }
+
+    @Test
+    public void testLookupIconDescriptor() {
+        final IconDescriptor descriptor = IconDescriptor.createLookupIcon(ICON_URL);
+
+        Assert.assertEquals(ICON_URL, descriptor.getUrl());
+        Assert.assertNull(descriptor.getMimeType());
+        Assert.assertEquals(0, descriptor.getSize());
+        Assert.assertEquals(IconDescriptor.TYPE_LOOKUP, descriptor.getType());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+@RunWith(TestRunner.class)
+public class TestIconDescriptorComparator {
+    private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+    private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+    private static final String TEST_ICON_URL_3 = "http://www.example.com/favicon.ico";
+
+    private static final String TEST_MIME_TYPE = "image/png";
+    private static final int TEST_SIZE = 32;
+
+    @Test
+    public void testIconsWithTheSameUrlAreTreatedAsEqual() {
+        final IconDescriptor descriptor1 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+        final IconDescriptor descriptor2 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(0, comparator.compare(descriptor1, descriptor2));
+        Assert.assertEquals(0, comparator.compare(descriptor2, descriptor1));
+    }
+
+    @Test
+    public void testTouchIconsAreRankedHigherThanFavicons() {
+        final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE);
+        final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(faviconDescriptor, touchIconDescriptor));
+        Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, faviconDescriptor));
+    }
+
+    @Test
+    public void testFaviconsAndTouchIconsAreRankedHigherThanGenericIcons() {
+        final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+
+        final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+        final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(genericDescriptor, faviconDescriptor));
+        Assert.assertEquals(-1, comparator.compare(faviconDescriptor, genericDescriptor));
+
+        Assert.assertEquals(1, comparator.compare(genericDescriptor, touchIconDescriptor));
+        Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, genericDescriptor));
+    }
+
+    @Test
+    public void testLookupIconsAreRankedHigherThanGenericIcons() {
+        final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+        final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_2);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(genericDescriptor, lookupDescriptor));
+        Assert.assertEquals(-1, comparator.compare(lookupDescriptor, genericDescriptor));
+    }
+
+    @Test
+    public void testFaviconsAndTouchIconsAreRankedHigherThanLookupIcons() {
+        final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_1);
+
+        final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+        final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(lookupDescriptor, faviconDescriptor));
+        Assert.assertEquals(-1, comparator.compare(faviconDescriptor, lookupDescriptor));
+
+        Assert.assertEquals(1, comparator.compare(lookupDescriptor, touchIconDescriptor));
+        Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, lookupDescriptor));
+    }
+
+    @Test
+    public void testLargestIconOfSameTypeIsSelected() {
+        final IconDescriptor smallDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, 16, TEST_MIME_TYPE);
+        final IconDescriptor largeDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, TEST_MIME_TYPE);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(smallDescriptor, largeDescriptor));
+        Assert.assertEquals(-1, comparator.compare(largeDescriptor, smallDescriptor));
+    }
+
+    @Test
+    public void testContainerTypesArePreferred() {
+        final IconDescriptor containerDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, "image/x-icon");
+        final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, "image/png");
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertEquals(1, comparator.compare(faviconDescriptor, containerDescriptor));
+        Assert.assertEquals(-1, comparator.compare(containerDescriptor, faviconDescriptor));
+    }
+
+    @Test
+    public void testWithNoDifferences() {
+        final IconDescriptor descriptor1 = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE);
+        final IconDescriptor descriptor2 = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+
+        final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+        Assert.assertNotEquals(0, comparator.compare(descriptor1, descriptor2));
+        Assert.assertNotEquals(0, comparator.compare(descriptor2, descriptor1));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconRequest {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+    private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+
+    @Test
+    public void testIconHandling() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .build();
+
+        Assert.assertEquals(0, request.getIconCount());
+        Assert.assertFalse(request.hasIconDescriptors());
+
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+                .deferBuild();
+
+        Assert.assertEquals(1, request.getIconCount());
+        Assert.assertTrue(request.hasIconDescriptors());
+
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2))
+                .deferBuild();
+
+        Assert.assertEquals(2, request.getIconCount());
+        Assert.assertTrue(request.hasIconDescriptors());
+
+        Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl());
+
+        request.moveToNextIcon();
+
+        Assert.assertEquals(1, request.getIconCount());
+        Assert.assertTrue(request.hasIconDescriptors());
+
+        Assert.assertEquals(TEST_ICON_URL_2, request.getBestIcon().getUrl());
+
+        request.moveToNextIcon();
+
+        Assert.assertEquals(0, request.getIconCount());
+        Assert.assertFalse(request.hasIconDescriptors());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconRequestBuilder {
+    private static final String TEST_PAGE_URL_1 = "http://www.mozilla.org";
+    private static final String TEST_PAGE_URL_2 = "http://www.example.org";
+    private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+    private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+
+    @Test
+    public void testPrivileged() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertFalse(request.isPrivileged());
+
+        request.modify()
+                .privileged(true)
+                .deferBuild();
+
+        Assert.assertTrue(request.isPrivileged());
+    }
+
+    @Test
+    public void testPageUrl() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertEquals(TEST_PAGE_URL_1, request.getPageUrl());
+
+        request.modify()
+                .pageUrl(TEST_PAGE_URL_2)
+                .deferBuild();
+
+        Assert.assertEquals(TEST_PAGE_URL_2, request.getPageUrl());
+    }
+
+    @Test
+    public void testIcons() {
+        // Initially a request is empty.
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        // Adding one icon URL.
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+                .deferBuild();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        // Adding the same icon URL again is ignored.
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+                .deferBuild();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        // Adding another new icon URL.
+        request.modify()
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2))
+                .deferBuild();
+
+        Assert.assertEquals(2, request.getIconCount());
+    }
+
+    @Test
+    public void testSkipNetwork() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertFalse(request.shouldSkipNetwork());
+
+        request.modify()
+                .skipNetwork()
+                .deferBuild();
+
+        Assert.assertTrue(request.shouldSkipNetwork());
+    }
+
+    @Test
+    public void testSkipDisk() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertFalse(request.shouldSkipDisk());
+
+        request.modify()
+                .skipDisk()
+                .deferBuild();
+
+        Assert.assertTrue(request.shouldSkipDisk());
+    }
+
+    @Test
+    public void testSkipMemory() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertFalse(request.shouldSkipMemory());
+
+        request.modify()
+                .skipMemory()
+                .deferBuild();
+
+        Assert.assertTrue(request.shouldSkipMemory());
+    }
+
+    @Test
+    public void testExecutionOnBackgroundThread() {
+        IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL_1)
+                .build();
+
+        Assert.assertFalse(request.shouldRunOnBackgroundThread());
+
+        request.modify()
+                .executeCallbackOnBackgroundThread()
+                .deferBuild();
+
+        Assert.assertTrue(request.shouldRunOnBackgroundThread());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestIconResponse {
+    private static final String ICON_URL = "http://www.mozilla.org/favicon.ico";
+
+    @Test
+    public void testDefaultResponse() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.create(bitmap);
+
+        Assert.assertEquals(bitmap, response.getBitmap());
+        Assert.assertFalse(response.hasUrl());
+        Assert.assertNull(response.getUrl());
+
+        Assert.assertFalse(response.hasColor());
+        Assert.assertEquals(0, response.getColor());
+
+        Assert.assertFalse(response.isGenerated());
+        Assert.assertFalse(response.isFromNetwork());
+        Assert.assertFalse(response.isFromDisk());
+        Assert.assertFalse(response.isFromMemory());
+    }
+
+    @Test
+    public void testNetworkResponse() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.createFromNetwork(bitmap, ICON_URL);
+
+        Assert.assertEquals(bitmap, response.getBitmap());
+        Assert.assertTrue(response.hasUrl());
+        Assert.assertEquals(ICON_URL, response.getUrl());
+
+        Assert.assertFalse(response.hasColor());
+        Assert.assertEquals(0, response.getColor());
+
+        Assert.assertFalse(response.isGenerated());
+        Assert.assertTrue(response.isFromNetwork());
+        Assert.assertFalse(response.isFromDisk());
+        Assert.assertFalse(response.isFromMemory());
+    }
+
+    @Test
+    public void testGeneratedResponse() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.createGenerated(bitmap, Color.CYAN);
+
+        Assert.assertEquals(bitmap, response.getBitmap());
+        Assert.assertFalse(response.hasUrl());
+        Assert.assertNull(response.getUrl());
+
+        Assert.assertTrue(response.hasColor());
+        Assert.assertEquals(Color.CYAN, response.getColor());
+
+        Assert.assertTrue(response.isGenerated());
+        Assert.assertFalse(response.isFromNetwork());
+        Assert.assertFalse(response.isFromDisk());
+        Assert.assertFalse(response.isFromMemory());
+    }
+
+    @Test
+    public void testMemoryResponse() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.createFromMemory(bitmap, ICON_URL, Color.CYAN);
+
+        Assert.assertEquals(bitmap, response.getBitmap());
+        Assert.assertTrue(response.hasUrl());
+        Assert.assertEquals(ICON_URL, response.getUrl());
+
+        Assert.assertTrue(response.hasColor());
+        Assert.assertEquals(Color.CYAN, response.getColor());
+
+        Assert.assertFalse(response.isGenerated());
+        Assert.assertFalse(response.isFromNetwork());
+        Assert.assertFalse(response.isFromDisk());
+        Assert.assertTrue(response.isFromMemory());
+    }
+
+    @Test
+    public void testDiskResponse() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.createFromDisk(bitmap, ICON_URL);
+
+        Assert.assertEquals(bitmap, response.getBitmap());
+        Assert.assertTrue(response.hasUrl());
+        Assert.assertEquals(ICON_URL, response.getUrl());
+
+        Assert.assertFalse(response.hasColor());
+        Assert.assertEquals(0, response.getColor());
+
+        Assert.assertFalse(response.isGenerated());
+        Assert.assertFalse(response.isFromNetwork());
+        Assert.assertTrue(response.isFromDisk());
+        Assert.assertFalse(response.isFromMemory());
+    }
+
+    @Test
+    public void testUpdatingColor() {
+        final IconResponse response = IconResponse.create(mock(Bitmap.class));
+
+        Assert.assertFalse(response.hasColor());
+        Assert.assertEquals(0, response.getColor());
+
+        response.updateColor(Color.YELLOW);
+
+        Assert.assertTrue(response.hasColor());
+        Assert.assertEquals(Color.YELLOW, response.getColor());
+
+        response.updateColor(Color.MAGENTA);
+
+        Assert.assertTrue(response.hasColor());
+        Assert.assertEquals(Color.MAGENTA, response.getColor());
+    }
+
+    @Test
+    public void testUpdatingBitmap() {
+        final Bitmap originalBitmap = mock(Bitmap.class);
+        final Bitmap updatedBitmap = mock(Bitmap.class);
+
+        final IconResponse response = IconResponse.create(originalBitmap);
+
+        Assert.assertEquals(originalBitmap, response.getBitmap());
+        Assert.assertNotEquals(updatedBitmap, response.getBitmap());
+
+        response.updateBitmap(updatedBitmap);
+
+        Assert.assertNotEquals(originalBitmap, response.getBitmap());
+        Assert.assertEquals(updatedBitmap, response.getBitmap());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java
@@ -0,0 +1,536 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestIconTask {
+    @Test
+    public void testGeneratorIsInvokedIfAllLoadersFail() {
+        final List<IconLoader> loaders = Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                createFailingLoader());
+
+        final Bitmap bitmap = mock(Bitmap.class);
+        final IconLoader generator = createSuccessfulLoader(bitmap);
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                loaders,
+                Collections.<Processor>emptyList(),
+                generator);
+
+        final IconResponse response = task.call();
+
+        // Verify all loaders have been tried
+        for (IconLoader loader : loaders) {
+            verify(loader).load(request);
+        }
+
+        // Verify generator was called
+        verify(generator).load(request);
+
+        // Verify response contains generated bitmap
+        Assert.assertEquals(bitmap, response.getBitmap());
+    }
+
+    @Test
+    public void testGeneratorIsNotCalledIfOneLoaderWasSuccessful() {
+        final List<IconLoader> loaders = Collections.singletonList(
+                createSuccessfulLoader(mock(Bitmap.class)));
+
+        final IconLoader generator = createSuccessfulLoader(mock(Bitmap.class));
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                loaders,
+                Collections.<Processor>emptyList(),
+                generator);
+
+        final IconResponse response = task.call();
+
+        // Verify all loaders have been tried
+        for (IconLoader loader : loaders) {
+            verify(loader).load(request);
+        }
+
+        // Verify generator was NOT called
+        verify(generator, never()).load(request);
+
+        Assert.assertNotNull(response);
+    }
+
+    @Test
+    public void testNoLoaderIsInvokedForRequestWithoutUrls() {
+        final List<IconLoader> loaders = Collections.singletonList(
+                createSuccessfulLoader(mock(Bitmap.class)));
+
+        final Bitmap bitmap = mock(Bitmap.class);
+        final IconLoader generator = createSuccessfulLoader(bitmap);
+
+        final IconRequest request = createIconRequestWithoutUrls();
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                loaders,
+                Collections.<Processor>emptyList(),
+                generator);
+
+        final IconResponse response = task.call();
+
+        // Verify NO loaders have been called
+        for (IconLoader loader : loaders) {
+            verify(loader, never()).load(request);
+        }
+
+        // Verify generator was called
+        verify(generator).load(request);
+
+        // Verify response contains generated bitmap
+        Assert.assertEquals(bitmap, response.getBitmap());
+    }
+
+    @Test
+    public void testAllPreparersAreCalledBeforeLoading() {
+        final List<Preparer> preparers = Arrays.asList(
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class));
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                preparers,
+                createListWithSuccessfulLoader(),
+                Collections.<Processor>emptyList(),
+                createGenerator());
+
+        task.call();
+
+        // Verify all preparers have been called
+        for (Preparer preparer : preparers) {
+            verify(preparer).prepare(request);
+        }
+    }
+
+    @Test
+    public void testSubsequentLoadersAreNotCalledAfterSuccessfulLoad() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final List<IconLoader> loaders = Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                createSuccessfulLoader(bitmap),
+                createSuccessfulLoader(mock(Bitmap.class)),
+                createFailingLoader(),
+                createSuccessfulLoader(mock(Bitmap.class)));
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                loaders,
+                Collections.<Processor>emptyList(),
+                createGenerator());
+
+        final IconResponse response = task.call();
+
+        // First loaders are called
+        verify(loaders.get(0)).load(request);
+        verify(loaders.get(1)).load(request);
+        verify(loaders.get(2)).load(request);
+
+        // Loaders after successful load are not called
+        verify(loaders.get(3), never()).load(request);
+        verify(loaders.get(4), never()).load(request);
+        verify(loaders.get(5), never()).load(request);
+
+        Assert.assertNotNull(response);
+        Assert.assertEquals(bitmap, response.getBitmap());
+    }
+
+    @Test
+    public void testNoProcessorIsCalledForUnsuccessfulLoads() {
+        final IconRequest request = createIconRequest();
+
+        final List<IconLoader> loaders = createListWithFailingLoaders();
+
+        final List<Processor> processors = Arrays.asList(
+            createProcessor(),
+            createProcessor(),
+            createProcessor());
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                loaders,
+                processors,
+                createFailingLoader());
+
+        task.call();
+
+        // Verify all loaders have been tried
+        for (IconLoader loader : loaders) {
+            verify(loader).load(request);
+        }
+
+        // Verify no processor was called
+        for (Processor processor : processors) {
+            verify(processor, never()).process(any(IconRequest.class), any(IconResponse.class));
+        }
+    }
+
+    @Test
+    public void testAllProcessorsAreCalledAfterSuccessfulLoad() {
+        final IconRequest request = createIconRequest();
+
+        final List<Processor> processors = Arrays.asList(
+                createProcessor(),
+                createProcessor(),
+                createProcessor());
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                createListWithSuccessfulLoader(),
+                processors,
+                createGenerator());
+
+        IconResponse response = task.call();
+
+        Assert.assertNotNull(response);
+
+        // Verify that all processors have been called
+        for (Processor processor : processors) {
+            verify(processor).process(request, response);
+        }
+    }
+
+    @Test
+    public void testCallbackIsExecutedForSuccessfulLoads() {
+        final IconCallback callback = mock(IconCallback.class);
+
+        final IconRequest request = createIconRequest();
+        request.setCallback(callback);
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                createListWithSuccessfulLoader(),
+                Collections.<Processor>emptyList(),
+                createGenerator());
+
+        final IconResponse response = task.call();
+
+        verify(callback).onIconResponse(response);
+    }
+
+    @Test
+    public void testCallbackIsNotExecutedIfLoadingFailed() {
+        final IconCallback callback = mock(IconCallback.class);
+
+        final IconRequest request = createIconRequest();
+        request.setCallback(callback);
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                createListWithFailingLoaders(),
+                Collections.<Processor>emptyList(),
+                createFailingLoader());
+
+        task.call();
+
+        verify(callback, never()).onIconResponse(any(IconResponse.class));
+    }
+
+    @Test
+    public void testCallbackIsExecutedWithGeneratorResult() {
+        final IconCallback callback = mock(IconCallback.class);
+
+        final IconRequest request = createIconRequest();
+        request.setCallback(callback);
+
+        final IconTask task = new IconTask(
+                request,
+                Collections.<Preparer>emptyList(),
+                createListWithFailingLoaders(),
+                Collections.<Processor>emptyList(),
+                createGenerator());
+
+        final IconResponse response = task.call();
+
+        verify(callback).onIconResponse(response);
+    }
+
+    @Test
+    public void testTaskCancellationWhileLoading() {
+        // We simulate the cancellation by injecting a loader that interrupts the thread.
+        final IconLoader cancellingLoader = spy(new IconLoader() {
+            @Override
+            public IconResponse load(IconRequest request) {
+                Thread.currentThread().interrupt();
+                return null;
+            }
+        });
+
+        final List<Preparer> preparers = createListOfPreparers();
+        final List<Processor> processors = createListOfProcessors();
+
+        final List<IconLoader> loaders = Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                cancellingLoader,
+                createFailingLoader(),
+                createSuccessfulLoader(mock(Bitmap.class)));
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                preparers,
+                loaders,
+                processors,
+                createGenerator());
+
+        final IconResponse response = task.call();
+        Assert.assertNull(response);
+
+        // Verify that all preparers are called
+        for (Preparer preparer : preparers) {
+            verify(preparer).prepare(request);
+        }
+
+        // Verify that first loaders are called
+        verify(loaders.get(0)).load(request);
+        verify(loaders.get(1)).load(request);
+
+        // Verify that our loader that interrupts the thread is called
+        verify(loaders.get(2)).load(request);
+
+        // Verify that all other loaders are not called
+        verify(loaders.get(3), never()).load(request);
+        verify(loaders.get(4), never()).load(request);
+
+        // Verify that no processors are called
+        for (Processor processor : processors) {
+            verify(processor, never()).process(eq(request), any(IconResponse.class));
+        }
+    }
+
+    @Test
+    public void testTaskCancellationWhileProcessing() {
+        final Processor cancellingProcessor = spy(new Processor() {
+            @Override
+            public void process(IconRequest request, IconResponse response) {
+                Thread.currentThread().interrupt();
+            }
+        });
+
+        final List<Preparer> preparers = createListOfPreparers();
+
+        final List<IconLoader> loaders = Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                createSuccessfulLoader(mock(Bitmap.class)));
+
+        final List<Processor> processors = Arrays.asList(
+                createProcessor(),
+                createProcessor(),
+                cancellingProcessor,
+                createProcessor(),
+                createProcessor());
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                preparers,
+                loaders,
+                processors,
+                createGenerator());
+
+        final IconResponse response = task.call();
+        Assert.assertNull(response);
+
+        // Verify that all preparers are called
+        for (Preparer preparer : preparers) {
+            verify(preparer).prepare(request);
+        }
+
+        // Verify that all loaders are called
+        for (IconLoader loader : loaders) {
+            verify(loader).load(request);
+        }
+
+        // Verify that first processors are called
+        verify(processors.get(0)).process(eq(request), any(IconResponse.class));
+        verify(processors.get(1)).process(eq(request), any(IconResponse.class));
+
+        // Verify that cancelling processor is called
+        verify(processors.get(2)).process(eq(request), any(IconResponse.class));
+
+        // Verify that subsequent processors are not called
+        verify(processors.get(3), never()).process(eq(request), any(IconResponse.class));
+        verify(processors.get(4), never()).process(eq(request), any(IconResponse.class));
+    }
+
+    @Test
+    public void testTaskCancellationWhilePerparing() {
+        final Preparer failingPreparer = spy(new Preparer() {
+            @Override
+            public void prepare(IconRequest request) {
+                Thread.currentThread().interrupt();
+            }
+        });
+
+        final List<Preparer> preparers = Arrays.asList(
+                mock(Preparer.class),
+                mock(Preparer.class),
+                failingPreparer,
+                mock(Preparer.class),
+                mock(Preparer.class));
+
+        final List<IconLoader> loaders = createListWithSuccessfulLoader();
+        final List<Processor> processors = createListOfProcessors();
+
+        final IconRequest request = createIconRequest();
+
+        final IconTask task = new IconTask(
+                request,
+                preparers,
+                loaders,
+                processors,
+                createGenerator());
+
+        final IconResponse response = task.call();
+        Assert.assertNull(response);
+
+        // Verify that first preparers are called
+        verify(preparers.get(0)).prepare(request);
+        verify(preparers.get(1)).prepare(request);
+
+        // Verify that cancelling preparer is called
+        verify(preparers.get(2)).prepare(request);
+
+        // Verify that subsequent preparers are not called
+        verify(preparers.get(3), never()).prepare(request);
+        verify(preparers.get(4), never()).prepare(request);
+
+        // Verify that no loaders are called
+        for (IconLoader loader : loaders) {
+            verify(loader, never()).load(request);
+        }
+
+        // Verify that no processors are called
+        for (Processor processor : processors) {
+            verify(processor, never()).process(eq(request), any(IconResponse.class));
+        }
+    }
+
+    public List<IconLoader> createListWithSuccessfulLoader() {
+        return Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                createSuccessfulLoader(mock(Bitmap.class)),
+                createFailingLoader());
+    }
+
+    public List<IconLoader> createListWithFailingLoaders() {
+        return Arrays.asList(
+                createFailingLoader(),
+                createFailingLoader(),
+                createFailingLoader(),
+                createFailingLoader(),
+                createFailingLoader());
+    }
+
+    public List<Preparer> createListOfPreparers() {
+        return Arrays.asList(
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class),
+                mock(Preparer.class));
+    }
+
+    public IconLoader createFailingLoader() {
+        final IconLoader loader = mock(IconLoader.class);
+        doReturn(null).when(loader).load(any(IconRequest.class));
+        return loader;
+    }
+
+    public IconLoader createSuccessfulLoader(Bitmap bitmap) {
+        IconResponse response = IconResponse.create(bitmap);
+
+        final IconLoader loader = mock(IconLoader.class);
+        doReturn(response).when(loader).load(any(IconRequest.class));
+        return loader;
+    }
+
+    public List<Processor> createListOfProcessors() {
+        return Arrays.asList(
+                mock(Processor.class),
+                mock(Processor.class),
+                mock(Processor.class),
+                mock(Processor.class),
+                mock(Processor.class));
+    }
+
+    public IconRequest createIconRequest() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon("http://www.mozilla.org/favicon.ico"))
+                .build();
+    }
+
+    public IconRequest createIconRequestWithoutUrls() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .build();
+    }
+
+    public IconLoader createGenerator() {
+        return createSuccessfulLoader(mock(Bitmap.class));
+    }
+
+    public Processor createProcessor() {
+        return mock(Processor.class);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.annotation.SuppressLint;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconsHelper {
+    @SuppressLint("AuthLeak") // Lint and Android Studio try to prevent developers from writing code
+                              // with credentials in the URL (user:password@host). But in this case
+                              // we explicitly want to do that, so we suppress the warnings.
+    @Test
+    public void testGuessDefaultFaviconURL() {
+        // Empty values
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL(null));
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL(""));
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("    "));
+
+        // Special about: URLs.
+
+        Assert.assertEquals(
+                "about:home",
+                IconsHelper.guessDefaultFaviconURL("about:home"));
+
+        Assert.assertEquals(
+                "about:",
+                IconsHelper.guessDefaultFaviconURL("about:"));
+
+        Assert.assertEquals(
+                "about:addons",
+                IconsHelper.guessDefaultFaviconURL("about:addons"));
+
+        // Non http(s) URLS
+
+        final String jarUrl = GeckoJarReader.getJarURL(RuntimeEnvironment.application, "chrome/chrome/content/branding/favicon64.png");
+        Assert.assertEquals(jarUrl, IconsHelper.guessDefaultFaviconURL(jarUrl));
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("content://some.random.provider/icons"));
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("ftp://ftp.public.mozilla.org/this/is/made/up"));
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///"));
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///system/path"));
+
+        // Various http(s) URLs
+
+        Assert.assertEquals("http://www.mozilla.org/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("http://www.mozilla.org/"));
+
+        Assert.assertEquals("https://www.mozilla.org/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("https://www.mozilla.org/en-US/firefox/products/"));
+
+        Assert.assertEquals("https://example.org/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("https://example.org"));
+
+        Assert.assertEquals("http://user:password@example.org:9991/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("http://user:password@example.org:9991/status/760492829949001728"));
+
+        Assert.assertEquals("https://localhost:8888/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("https://localhost:8888/path/folder/file?some=query&params=none"));
+
+        Assert.assertEquals("http://192.168.0.1/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("http://192.168.0.1/local/action.cgi"));
+
+        Assert.assertEquals("https://medium.com/favicon.ico",
+                IconsHelper.guessDefaultFaviconURL("https://medium.com/firefox-mobile-engineering/firefox-for-android-hack-week-recap-f1ab12f5cc44#.rpmzz15ia"));
+
+        // Some broken, partial URLs
+
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http:"));
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http://"));
+        Assert.assertNull(IconsHelper.guessDefaultFaviconURL("https:/"));
+    }
+
+    @Test
+    public void testIsContainerType() {
+        // Empty values
+        Assert.assertFalse(IconsHelper.isContainerType(null));
+        Assert.assertFalse(IconsHelper.isContainerType(""));
+        Assert.assertFalse(IconsHelper.isContainerType("   "));
+
+        // Values that don't make any sense.
+        Assert.assertFalse(IconsHelper.isContainerType("Hello World"));
+        Assert.assertFalse(IconsHelper.isContainerType("no/no/no"));
+        Assert.assertFalse(IconsHelper.isContainerType("42"));
+
+        // Actual image MIME types that are not container types
+        Assert.assertFalse(IconsHelper.isContainerType("image/png"));
+        Assert.assertFalse(IconsHelper.isContainerType("application/bmp"));
+        Assert.assertFalse(IconsHelper.isContainerType("image/gif"));
+        Assert.assertFalse(IconsHelper.isContainerType("image/x-windows-bitmap"));
+        Assert.assertFalse(IconsHelper.isContainerType("image/jpeg"));
+        Assert.assertFalse(IconsHelper.isContainerType("application/x-png"));
+
+        // MIME types of image container
+        Assert.assertTrue(IconsHelper.isContainerType("image/vnd.microsoft.icon"));
+        Assert.assertTrue(IconsHelper.isContainerType("image/ico"));
+        Assert.assertTrue(IconsHelper.isContainerType("image/icon"));
+        Assert.assertTrue(IconsHelper.isContainerType("image/x-icon"));
+        Assert.assertTrue(IconsHelper.isContainerType("text/ico"));
+        Assert.assertTrue(IconsHelper.isContainerType("application/ico"));
+    }
+
+    @Test
+    public void testCanDecodeType() {
+        // Empty values
+        Assert.assertFalse(IconsHelper.canDecodeType(null));
+        Assert.assertFalse(IconsHelper.canDecodeType(""));
+        Assert.assertFalse(IconsHelper.canDecodeType("   "));
+
+        // Some things we can't decode (or that just aren't images)
+        Assert.assertFalse(IconsHelper.canDecodeType("image/svg+xml"));
+        Assert.assertFalse(IconsHelper.canDecodeType("video/avi"));
+        Assert.assertFalse(IconsHelper.canDecodeType("text/plain"));
+        Assert.assertFalse(IconsHelper.canDecodeType("image/x-quicktime"));
+        Assert.assertFalse(IconsHelper.canDecodeType("image/tiff"));
+        Assert.assertFalse(IconsHelper.canDecodeType("application/zip"));
+
+        // Some image MIME types we definitely can decode
+        Assert.assertTrue(IconsHelper.canDecodeType("image/bmp"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/x-icon"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/png"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/jpg"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/jpeg"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/ico"));
+        Assert.assertTrue(IconsHelper.canDecodeType("image/icon"));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestContentProviderLoader {
+    @Test
+    public void testNothingIsLoadedForHttpUrls() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+                .build();
+
+        IconLoader loader = new ContentProviderLoader();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNull(response);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestDataUriLoader {
+    @Test
+    public void testNothingIsLoadedForHttpUrls() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+                .build();
+
+        IconLoader loader = new DataUriLoader();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNull(response);
+    }
+
+    @Test
+    public void testIconIsLoadedFromDataUri() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC"))
+                .build();
+
+        IconLoader loader = new DataUriLoader();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNotNull(response);
+        Assert.assertNotNull(response.getBitmap());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.OutputStream;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestDiskLoader {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+    @Test
+    public void testLoadingFromEmptyCache() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        final IconLoader loader = new DiskLoader();
+        final IconResponse response = loader.load(request);
+
+        Assert.assertNull(response);
+    }
+
+    @Test
+    public void testLoadingAfterAddingEntry() {
+        final Bitmap bitmap = createMockedBitmap();
+        final IconResponse originalResponse = IconResponse.createFromNetwork(bitmap, TEST_ICON_URL);
+
+        DiskStorage.get(RuntimeEnvironment.application)
+                .putIcon(originalResponse);
+
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        final IconLoader loader = new DiskLoader();
+        final IconResponse loadedResponse = loader.load(request);
+
+        Assert.assertNotNull(loadedResponse);
+
+        // The responses are not the same: The original response was stored to disk and loaded from
+        // disk again. It's a copy effectively.
+        Assert.assertNotEquals(originalResponse, loadedResponse);
+    }
+
+    @Test
+    public void testNothingIsLoadedIfDiskShouldBeSkipped() {
+        final Bitmap bitmap = createMockedBitmap();
+        final IconResponse originalResponse = IconResponse.createFromNetwork(bitmap, TEST_ICON_URL);
+
+        DiskStorage.get(RuntimeEnvironment.application)
+                .putIcon(originalResponse);
+
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .skipDisk()
+                .build();
+
+        final IconLoader loader = new DiskLoader();
+        final IconResponse loadedResponse = loader.load(request);
+
+        Assert.assertNull(loadedResponse);
+    }
+
+    private Bitmap createMockedBitmap() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        doReturn(true).when(bitmap).compress(any(Bitmap.CompressFormat.class), anyInt(), any(OutputStream.class));
+
+        return bitmap;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.robolectric.RuntimeEnvironment;
+
+import java.net.HttpURLConnection;
+
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestIconDownloader {
+    /**
+     * Scenario: A request with a non HTTP URL (data:image/*) is executed.
+     *
+     * Verify that:
+     *  * No download is performed.
+     */
+    @Test
+    public void testDownloaderDoesNothingForNonHttpUrls() throws Exception {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC"))
+                .build();
+
+        final IconDownloader downloader = spy(new IconDownloader());
+        IconResponse response = downloader.load(request);
+
+        Assert.assertNull(response);
+
+        verify(downloader, never()).downloadAndDecodeImage(anyString());
+        verify(downloader, never()).connectTo(anyString());
+    }
+
+    /**
+     * Scenario: Request contains an URL and server returns 301 with location header (always the same URL).
+     *
+     * Verify that:
+     *  * Download code stops and does not loop forever.
+     */
+    @Test
+    public void testRedirectsAreFollowedButNotInCircles() throws Exception {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createFavicon(
+                        "https://www.mozilla.org/media/img/favicon.52506929be4c.ico",
+                        32,
+                        "image/x-icon"))
+                .build();
+
+        HttpURLConnection mockedConnection = mock(HttpURLConnection.class);
+        doReturn(301).when(mockedConnection).getResponseCode();
+        doReturn("http://example.org/favicon.ico").when(mockedConnection).getHeaderField("Location");
+
+        final IconDownloader downloader = spy(new IconDownloader());
+        doReturn(mockedConnection).when(downloader).connectTo(anyString());
+        IconResponse response = downloader.load(request);
+
+        Assert.assertNull(response);
+
+        verify(downloader).connectTo("https://www.mozilla.org/media/img/favicon.52506929be4c.ico");
+        verify(downloader).connectTo("http://example.org/favicon.ico");
+    }
+
+    /**
+     * Scenario: Request contains an URL and server returns HTTP 404.
+     *
+     * Verify that:
+     *  * URL is added to failure cache.
+     */
+    @Test
+    public void testUrlIsAddedToFailureCacheIfServerReturnsClientError() throws Exception {
+        final String faviconUrl = "https://www.mozilla.org/404.ico";
+
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createFavicon(faviconUrl, 32, "image/x-icon"))
+                .build();
+
+        HttpURLConnection mockedConnection = mock(HttpURLConnection.class);
+        doReturn(404).when(mockedConnection).getResponseCode();
+
+        Assert.assertFalse(FailureCache.get().isKnownFailure(faviconUrl));
+
+        final IconDownloader downloader = spy(new IconDownloader());
+        doReturn(mockedConnection).when(downloader).connectTo(anyString());
+        IconResponse response = downloader.load(request);
+
+        Assert.assertNull(response);
+
+        Assert.assertTrue(FailureCache.get().isKnownFailure(faviconUrl));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconGenerator {
+    @Test
+    public void testNoIconIsGeneratorIfThereAreIconUrlsToLoadFrom() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon.52506929be4c.ico"))
+                .build();
+
+        IconLoader loader = new IconGenerator();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNull(response);
+    }
+
+    @Test
+    public void testIconIsGeneratedForLastUrl() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+                .build();
+
+        IconLoader loader = new IconGenerator();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNotNull(response);
+        Assert.assertNotNull(response.getBitmap());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestJarLoader {
+    @Test
+    public void testNothingIsLoadedForHttpUrls() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createGenericIcon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+                .build();
+
+        IconLoader loader = new JarLoader();
+        IconResponse response = loader.load(request);
+
+        Assert.assertNull(response);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestLegacyLoader {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+    @Test
+    public void testDatabaseIsQueriesForNormalRequests() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        final LegacyLoader loader = spy(new LegacyLoader());
+        final IconResponse response = loader.load(request);
+
+        verify(loader).loadBitmapFromDatabase(request);
+
+        Assert.assertNull(response);
+    }
+
+    @Test
+    public void testNothingIsLoadedIfDiskSHouldBeSkipped() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .skipDisk()
+                .build();
+
+        final LegacyLoader loader = spy(new LegacyLoader());
+        final IconResponse response = loader.load(request);
+
+        verify(loader, never()).loadBitmapFromDatabase(request);
+
+        Assert.assertNull(response);
+    }
+
+    @Test
+    public void testLoadedBitmapIsReturnedAsResponse() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        final LegacyLoader loader = spy(new LegacyLoader());
+        doReturn(bitmap).when(loader).loadBitmapFromDatabase(request);
+
+        final IconResponse response = loader.load(request);
+
+        Assert.assertNotNull(response);
+        Assert.assertEquals(bitmap, response.getBitmap());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestMemoryLoader {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+    @Before
+    public void setUp() {
+        // Make sure to start with an empty memory cache.
+        MemoryStorage.get().evictAll();
+    }
+
+    @Test
+    public void testStoringAndLoadingFromMemory() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        final IconLoader loader = new MemoryLoader();
+
+        Assert.assertNull(loader.load(request));
+
+        final Bitmap bitmap = mock(Bitmap.class);
+        final IconResponse response = IconResponse.create(bitmap);
+        response.updateColor(Color.MAGENTA);
+
+        MemoryStorage.get().putIcon(TEST_ICON_URL, response);
+
+        final IconResponse loadedResponse = loader.load(request);
+
+        Assert.assertNotNull(loadedResponse);
+        Assert.assertEquals(bitmap, loadedResponse.getBitmap());
+        Assert.assertEquals(Color.MAGENTA, loadedResponse.getColor());
+    }
+
+    @Test
+    public void testNothingIsLoadedIfMemoryShouldBeSkipped() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .skipMemory()
+                .build();
+
+        final IconLoader loader = new MemoryLoader();
+
+        Assert.assertNull(loader.load(request));
+
+        final Bitmap bitmap = mock(Bitmap.class);
+        final IconResponse response = IconResponse.create(bitmap);
+
+        MemoryStorage.get().putIcon(TEST_ICON_URL, response);
+
+        Assert.assertNull(loader.load(request));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestAboutPagesPreparer {
+    private static final String[] ABOUT_PAGES = {
+            AboutPages.ACCOUNTS,
+            AboutPages.ADDONS,
+            AboutPages.CONFIG,
+            AboutPages.DOWNLOADS,
+            AboutPages.FIREFOX,
+            AboutPages.HEALTHREPORT,
+            AboutPages.HOME,
+            AboutPages.UPDATER
+    };
+
+    @Test
+    public void testPreparerAddsUrlsForAllAboutPages() {
+        final Preparer preparer = new AboutPagesPreparer();
+
+        for (String url : ABOUT_PAGES) {
+            final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                    .pageUrl(url)
+                    .build();
+
+            Assert.assertEquals(0, request.getIconCount());
+
+            preparer.prepare(request);
+
+            Assert.assertEquals("Added icon URL for URL: " + url, 1, request.getIconCount());
+        }
+    }
+
+    @Test
+    public void testPrepareDoesNotAddUrlForGenericHttpUrl() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .build();
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        final Preparer preparer = new AboutPagesPreparer();
+        preparer.prepare(request);
+
+        Assert.assertEquals(0, request.getIconCount());
+    }
+
+    @Test
+    public void testAddedUrlHasJarScheme() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(AboutPages.DOWNLOADS)
+                .build();
+
+        final Preparer preparer = new AboutPagesPreparer();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        final String url = request.getBestIcon().getUrl();
+        Assert.assertNotNull(url);
+        Assert.assertTrue(url.startsWith("jar:jar:"));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Iterator;
+
+@RunWith(TestRunner.class)
+public class TestAddDefaultIconUrl {
+    @Test
+    public void testAddingDefaultUrl() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createTouchicon(
+                        "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png",
+                        180,
+                        "image/png"))
+                .icon(IconDescriptor.createFavicon(
+                        "https://www.mozilla.org/media/img/favicon.52506929be4c.ico",
+                        32,
+                        "image/x-icon"))
+                .icon(IconDescriptor.createFavicon(
+                        "jar:jar:wtf.png",
+                        16,
+                        "image/png"))
+                .build();
+
+
+        Assert.assertEquals(3, request.getIconCount());
+        Assert.assertFalse(containsUrl(request, "http://www.mozilla.org/favicon.ico"));
+
+        Preparer preparer = new AddDefaultIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(4, request.getIconCount());
+        Assert.assertTrue(containsUrl(request, "http://www.mozilla.org/favicon.ico"));
+    }
+
+    @Test
+    public void testDefaultUrlIsNotAddedIfItAlreadyExists() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl("http://www.mozilla.org")
+                .icon(IconDescriptor.createFavicon(
+                        "http://www.mozilla.org/favicon.ico",
+                        32,
+                        "image/x-icon"))
+                .build();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        Preparer preparer = new AddDefaultIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+    }
+
+    private boolean containsUrl(IconRequest request, String url) {
+        final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+        while (iterator.hasNext()) {
+            IconDescriptor descriptor = iterator.next();
+
+            if (descriptor.getUrl().equals(url)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestFilterKnownFailureUrls {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+    @Before
+    public void setUp() {
+        // Make sure we always start with an empty cache.
+        FailureCache.get().evictAll();
+    }
+
+    @Test
+    public void testFilterDoesNothingByDefault() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        final Preparer preparer = new FilterKnownFailureUrls();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+    }
+
+    @Test
+    public void testFilterKnownFailureUrls() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        FailureCache.get().rememberFailure(TEST_ICON_URL);
+
+        final Preparer preparer = new FilterKnownFailureUrls();
+        preparer.prepare(request);
+
+        Assert.assertEquals(0, request.getIconCount());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestFilterMimeTypes {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+    private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+    private static final String TEST_ICON_URL_2 = "https://mozilla.org/favicon.ico";
+
+    @Test
+    public void testUrlsWithoutMimeTypesAreNotFiltered() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+                .build();
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        final Preparer preparer = new FilterMimeTypes();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+    }
+
+    @Test
+    public void testUnknownMimeTypesAreFiltered() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/zaphod"))
+                .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "audio/mpeg"))
+                .build();
+
+        Assert.assertEquals(2, request.getIconCount());
+
+        final Preparer preparer = new FilterMimeTypes();
+        preparer.prepare(request);
+
+        Assert.assertEquals(0, request.getIconCount());
+    }
+
+    @Test
+    public void testKnownMimeTypesAreNotFiltered() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/x-icon"))
+                .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "image/png"))
+                .build();
+
+        Assert.assertEquals(2, request.getIconCount());
+
+        final Preparer preparer = new FilterMimeTypes();
+        preparer.prepare(request);
+
+        Assert.assertEquals(2, request.getIconCount());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java
@@ -0,0 +1,86 @@
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Iterator;
+
+@RunWith(TestRunner.class)
+public class TestFilterPrivilegedUrls {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+
+    private static final String TEST_ICON_HTTP_URL = "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png";
+    private static final String TEST_ICON_HTTP_URL_2 = "https://www.mozilla.org/media/img/favicon.52506929be4c.ico";
+    private static final String TEST_ICON_JAR_URL = "jar:jar:wtf.png";
+
+    @Test
+    public void testFiltering() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL))
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2))
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL))
+                .build();
+
+        Assert.assertEquals(3, request.getIconCount());
+
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+
+        Preparer preparer = new FilterPrivilegedUrls();
+        preparer.prepare(request);
+
+        Assert.assertEquals(2, request.getIconCount());
+
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+        Assert.assertFalse(containsUrl(request, TEST_ICON_JAR_URL));
+    }
+
+    @Test
+    public void testNothingIsFilteredForPrivilegedRequests() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL))
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2))
+                .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL))
+                .privileged(true)
+                .build();
+
+        Assert.assertEquals(3, request.getIconCount());
+
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+
+        Preparer preparer = new FilterPrivilegedUrls();
+        preparer.prepare(request);
+
+        Assert.assertEquals(3, request.getIconCount());
+
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+        Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+    }
+
+    private boolean containsUrl(IconRequest request, String url) {
+        final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+        while (iterator.hasNext()) {
+            IconDescriptor descriptor = iterator.next();
+
+            if (descriptor.getUrl().equals(url)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestLookupIconUrl {
+    private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+
+    private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+    private static final String TEST_ICON_URL_2 = "http://example.org/favicon.ico";
+    private static final String TEST_ICON_URL_3 = "http://example.com/favicon.ico";
+    private static final String TEST_ICON_URL_4 = "http://example.net/favicon.ico";
+
+
+    @Before
+    public void setUp() {
+        MemoryStorage.get().evictAll();
+    }
+
+    @Test
+    public void testNoIconUrlIsAddedByDefault() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .build();
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        Preparer preparer = new LookupIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(0, request.getIconCount());
+    }
+
+    @Test
+    public void testIconUrlIsAddedFromMemory() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .build();
+
+        MemoryStorage.get().putMapping(request, TEST_ICON_URL_1);
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        Preparer preparer = new LookupIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl());
+    }
+
+    @Test
+    public void testIconUrlIsAddedFromDisk() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .build();
+
+        DiskStorage.get(RuntimeEnvironment.application).putMapping(request, TEST_ICON_URL_2);
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        Preparer preparer = new LookupIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        Assert.assertEquals(TEST_ICON_URL_2, request.getBestIcon().getUrl());
+    }
+
+    @Test
+    public void testIconUrlIsAddedFromMemoryBeforeUsingDiskStorage() {
+        final IconRequest request = Icons.with(RuntimeEnvironment.application)
+                .pageUrl(TEST_PAGE_URL)
+                .build();
+
+        MemoryStorage.get().putMapping(request, TEST_ICON_URL_3);
+        DiskStorage.get(RuntimeEnvironment.application).putMapping(request, TEST_ICON_URL_4);
+
+        Assert.assertEquals(0, request.getIconCount());
+
+        Preparer preparer = new LookupIconUrl();
+        preparer.prepare(request);
+
+        Assert.assertEquals(1, request.getIconCount());
+
+        Assert.assertEquals(TEST_ICON_URL_3, request.getBestIcon().getUrl());
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconResponse;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestColorProcessor {
+    @Test
+    public void testExtractingColor() {
+        final IconResponse response = IconResponse.create(createRedBitmapMock());
+
+        Assert.assertFalse(response.hasColor());
+        Assert.assertEquals(0, response.getColor());
+
+        final Processor processor = new ColorProcessor();
+        processor.process(null, response);
+
+        Assert.assertTrue(response.hasColor());
+        Assert.assertEquals(Color.RED, response.getColor());
+    }
+
+    private Bitmap createRedBitmapMock() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        doReturn(1).when(bitmap).getWidth();
+        doReturn(1).when(bitmap).getHeight();
+
+        doAnswer(new Answer<Void>() {
+            @Override
+            public Void answer(InvocationOnMock invocation) throws Throwable {
+                Object[] args = invocation.getArguments();
+                int[] pixels = (int[]) args[0];
+                for (int i = 0; i < pixels.length; i++) {
+                    pixels[i] = Color.RED;
+                }
+                return null;
+            }
+        }).when(bitmap).getPixels(any(int[].class), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+
+        return bitmap;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.OutputStream;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestDiskProcessor {
+    private static final String PAGE_URL = "https://www.mozilla.org";
+    private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+
+    @Test
+    public void testNetworkResponseIsStoredInCache() {
+        final IconRequest request = createTestRequest();
+        final IconResponse response = createTestNetworkResponse();
+
+        final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+        Assert.assertNull(storage.getIcon(ICON_URL));
+
+        final Processor processor = new DiskProcessor();
+        processor.process(request, response);
+
+        Assert.assertNotNull(storage.getIcon(ICON_URL));
+    }
+
+    @Test
+    public void testGeneratedResponseIsNotStored() {
+        final IconRequest request = createTestRequest();
+        final IconResponse response = createGeneratedResponse();
+
+        final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+        Assert.assertNull(storage.getIcon(ICON_URL));
+
+        final Processor processor = new DiskProcessor();
+        processor.process(request, response);
+
+        Assert.assertNull(storage.getIcon(ICON_URL));
+    }
+
+    @Test
+    public void testNothingIsStoredIfDiskShouldBeSkipped() {
+        final IconRequest request = createTestRequest()
+                .modify()
+                .skipDisk()
+                .build();
+        final IconResponse response = createTestNetworkResponse();
+
+        final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+        Assert.assertNull(storage.getIcon(ICON_URL));
+
+        final Processor processor = new DiskProcessor();
+        processor.process(request, response);
+
+        Assert.assertNull(storage.getIcon(ICON_URL));
+    }
+
+    private IconRequest createTestRequest() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl(PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(ICON_URL))
+                .build();
+    }
+
+    public IconResponse createTestNetworkResponse() {
+        return IconResponse.createFromNetwork(createMockedBitmap(), ICON_URL);
+    }
+
+    public IconResponse createGeneratedResponse() {
+        return IconResponse.createGenerated(createMockedBitmap(), Color.WHITE);
+    }
+
+    private Bitmap createMockedBitmap() {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        doReturn(true).when(bitmap).compress(any(Bitmap.CompressFormat.class), anyInt(), any(OutputStream.class));
+
+        return bitmap;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestMemoryProcessor {
+    private static final String PAGE_URL = "https://www.mozilla.org";
+    private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+    private static final String DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC";
+
+    @Before
+    public void setUp() {
+        MemoryStorage.get().evictAll();
+    }
+
+    @Test
+    public void testResponsesAreStoredInMemory() {
+        final IconRequest request = createTestRequest();
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+        final Processor processor = new MemoryProcessor();
+        processor.process(request, createTestResponse());
+
+        Assert.assertNotNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNotNull(MemoryStorage.get().getMapping(PAGE_URL));
+    }
+
+    @Test
+    public void testNothingIsStoredIfMemoryShouldBeSkipped() {
+        final IconRequest request = createTestRequest()
+                .modify()
+                .skipMemory()
+                .build();
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+        final Processor processor = new MemoryProcessor();
+        processor.process(request, createTestResponse());
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+    }
+
+    @Test
+    public void testNothingIsStoredForRequestsWithoutUrl() {
+        final IconRequest request = createTestRequestWithoutIconUrl();
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+        final Processor processor = new MemoryProcessor();
+        processor.process(request, createTestResponse());
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+    }
+
+    @Test
+    public void testNothingIsStoredForGeneratedResponses() {
+        final IconRequest request = createTestRequest();
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+        final Processor processor = new MemoryProcessor();
+        processor.process(request, createGeneratedTestResponse());
+
+        Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+    }
+
+    @Test
+    public void testNothingIsStoredForDataUris() {
+        final IconRequest request = createDataUriTestRequest();
+
+        Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+        final Processor processor = new MemoryProcessor();
+        processor.process(request, createTestResponse());
+
+        Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL));
+        Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+    }
+
+    private IconRequest createTestRequest() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl(PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(ICON_URL))
+                .build();
+    }
+
+    private IconRequest createTestRequestWithoutIconUrl() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl(PAGE_URL)
+                .build();
+    }
+
+    private IconRequest createDataUriTestRequest() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl(PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(DATA_URL))
+                .build();
+    }
+
+    private IconResponse createTestResponse() {
+        return IconResponse.create(mock(Bitmap.class));
+    }
+
+    private IconResponse createGeneratedTestResponse() {
+        return IconResponse.createGenerated(mock(Bitmap.class), Color.GREEN);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestResizingProcessor {
+    private static final String PAGE_URL = "https://www.mozilla.org";
+    private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+
+    @Test
+    public void testBitmapIsNotResizedIfItAlreadyHasTheTargetSize() {
+        final IconRequest request = createTestRequest();
+
+        final Bitmap bitmap = createBitmapMock(request.getTargetSize());
+        final IconResponse response = spy(IconResponse.create(bitmap));
+
+        final ResizingProcessor processor = spy(new ResizingProcessor());
+        processor.process(request, response);
+
+        verify(processor, never()).resize(any(Bitmap.class), anyInt());
+        verify(bitmap, never()).recycle();
+        verify(response, never()).updateBitmap(any(Bitmap.class));
+    }
+
+    @Test
+    public void testLargerBitmapsAreResized() {
+        final IconRequest request = createTestRequest();
+
+        final Bitmap bitmap = createBitmapMock(request.getTargetSize() * 2);
+        final IconResponse response = spy(IconResponse.create(bitmap));
+
+        final ResizingProcessor processor = spy(new ResizingProcessor());
+        final Bitmap resizedBitmap = mock(Bitmap.class);
+        doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+        processor.process(request, response);
+
+        verify(processor).resize(bitmap, request.getTargetSize());
+        verify(bitmap).recycle();
+        verify(response).updateBitmap(resizedBitmap);
+    }
+
+    @Test
+    public void testBitmapIsUpscaledToTargetSize() {
+        final IconRequest request = createTestRequest();
+
+        final Bitmap bitmap = createBitmapMock(request.getTargetSize() / 2 + 1);
+        final IconResponse response = spy(IconResponse.create(bitmap));
+
+        final ResizingProcessor processor = spy(new ResizingProcessor());
+        final Bitmap resizedBitmap = mock(Bitmap.class);
+        doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+        processor.process(request, response);
+
+        verify(processor).resize(bitmap, request.getTargetSize());
+        verify(bitmap).recycle();
+        verify(response).updateBitmap(resizedBitmap);
+    }
+
+    @Test
+    public void testBitmapIsNotScaledMoreThanTwoTimesTheSize() {
+        final IconRequest request = createTestRequest();
+
+        final Bitmap bitmap = createBitmapMock(5);
+        final IconResponse response = spy(IconResponse.create(bitmap));
+
+        final ResizingProcessor processor = spy(new ResizingProcessor());
+        final Bitmap resizedBitmap = mock(Bitmap.class);
+        doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+        processor.process(request, response);
+
+        verify(processor).resize(bitmap, 10);
+        verify(bitmap).recycle();
+        verify(response).updateBitmap(resizedBitmap);
+    }
+
+    private IconRequest createTestRequest() {
+        return Icons.with(RuntimeEnvironment.application)
+                .pageUrl(PAGE_URL)
+                .icon(IconDescriptor.createGenericIcon(ICON_URL))
+                .build();
+    }
+
+    private Bitmap createBitmapMock(int size) {
+        final Bitmap bitmap = mock(Bitmap.class);
+
+        doReturn(size).when(bitmap).getWidth();
+        doReturn(size).when(bitmap).getHeight();
+
+        return bitmap;
+    }
+}