Bug 1252610 - Extract SuggestedSites interface to simplify testing r=liuche draft
authorAndrzej Hunt <ahunt@mozilla.com>
Tue, 01 Mar 2016 13:33:47 -0800
changeset 335890 e9ece842484ea554ef91e3d0f947d5070979e7cb
parent 335889 8a7786a7f46f72a6491cae1ae006131fc29dfbca
child 335891 02c7011f04cc5c7459cad65cccc0b55fa3d60534
push id11916
push userahunt@mozilla.com
push dateTue, 01 Mar 2016 21:57:47 +0000
reviewersliuche
bugs1252610
milestone47.0a1
Bug 1252610 - Extract SuggestedSites interface to simplify testing r=liuche MozReview-Commit-ID: 1quN7BxNIbD
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/db/LocalSuggestedSites.java
mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/db/TestTopSites.java
mobile/android/tests/browser/junit3/src/org/mozilla/tests/browser/junit3/TestSuggestedSites.java
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -16,16 +16,17 @@ import org.mozilla.gecko.DynamicToolbar.
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.Tabs.TabEvents;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.TransitionsTracker;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.LocalSuggestedSites;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.dlc.DownloadContentService;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
 import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
@@ -700,17 +701,17 @@ public class BrowserApp extends GeckoApp
             "Sanitize:ClearSyncedTabs",
             "Settings:Show",
             "Telemetry:Gather",
             "Updater:Launch");
 
         Distribution distribution = Distribution.init(this);
 
         // Init suggested sites engine in BrowserDB.
-        final SuggestedSites suggestedSites = new SuggestedSites(appContext, distribution);
+        final SuggestedSites suggestedSites = new LocalSuggestedSites(appContext, distribution);
         final BrowserDB db = getProfile().getDB();
         db.setSuggestedSites(suggestedSites);
 
         JavaAddonManager.getInstance().init(appContext);
         mSharedPreferencesHelper = new SharedPreferencesHelper(appContext);
         mOrderedBroadcastHelper = new OrderedBroadcastHelper(appContext);
         mReadingListHelper = new ReadingListHelper(appContext, getProfile(), this);
         mAccountsHelper = new AccountsHelper(appContext, getProfile());
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalSuggestedSites.java
@@ -0,0 +1,523 @@
+/* -*- 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.db;
+
+import android.content.Context;
+import android.content.ContentResolver;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+
+import org.json.JSONArray;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.Restrictions;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.RawResource;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@RobocopTarget
+public class LocalSuggestedSites implements SuggestedSites {
+    private static final String LOGTAG = "GeckoSuggestedSites";
+
+    // File in profile dir with the list of suggested sites.
+    private static final String FILENAME = "suggestedsites.json";
+
+    private static final String[] COLUMNS = new String[] {
+        BrowserContract.SuggestedSites._ID,
+        BrowserContract.SuggestedSites.URL,
+        BrowserContract.SuggestedSites.TITLE,
+    };
+
+    final Context context;
+    final Distribution distribution;
+    private File cachedFile;
+    private Map<String, Site> cachedSites;
+    private Set<String> cachedBlacklist;
+
+    public LocalSuggestedSites(Context appContext) {
+        this(appContext, null);
+    }
+
+    public LocalSuggestedSites(Context appContext, Distribution distribution) {
+        this(appContext, distribution, null);
+    }
+
+    public LocalSuggestedSites(Context appContext, Distribution distribution, File file) {
+        this.context = appContext;
+        this.distribution = distribution;
+        this.cachedFile = file;
+    }
+
+    synchronized File getFile() {
+        if (cachedFile == null) {
+            cachedFile = GeckoProfile.get(context).getFile(FILENAME);
+        }
+        return cachedFile;
+    }
+
+    private static boolean isNewLocale(Context context, Locale requestedLocale) {
+        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+
+        String locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE, null);
+        if (locale == null) {
+            // Initialize config with the current locale
+            updateSuggestedSitesLocale(context);
+            return true;
+        }
+
+        return !TextUtils.equals(requestedLocale.toString(), locale);
+    }
+
+    /**
+     * Return the current locale and its fallback (en_US) in order.
+     */
+    private static List<Locale> getAcceptableLocales() {
+        final List<Locale> locales = new ArrayList<Locale>();
+
+        final Locale defaultLocale = Locale.getDefault();
+        locales.add(defaultLocale);
+
+        if (!defaultLocale.equals(Locale.US)) {
+            locales.add(Locale.US);
+        }
+
+        return locales;
+    }
+
+    private static Map<String, Site> loadSites(File f) throws IOException {
+        Scanner scanner = null;
+
+        try {
+            scanner = new Scanner(f, "UTF-8");
+            return loadSites(scanner.useDelimiter("\\A").next());
+        } finally {
+            if (scanner != null) {
+                scanner.close();
+            }
+        }
+    }
+
+    private static Map<String, Site> loadSites(String jsonString) {
+        if (TextUtils.isEmpty(jsonString)) {
+            return null;
+        }
+
+        Map<String, Site> sites = null;
+
+        try {
+            final JSONArray jsonSites = new JSONArray(jsonString);
+            sites = new LinkedHashMap<String, Site>(jsonSites.length());
+
+            final int count = jsonSites.length();
+            for (int i = 0; i < count; i++) {
+                final Site site = new Site(jsonSites.getJSONObject(i));
+                sites.put(site.url, site);
+            }
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Failed to refresh suggested sites", e);
+            return null;
+        }
+
+        return sites;
+    }
+
+    /**
+     * Saves suggested sites file to disk. Access to this method should
+     * be synchronized on 'file'.
+     */
+    static void saveSites(File f, Map<String, Site> sites) {
+        ThreadUtils.assertNotOnUiThread();
+
+        if (sites == null || sites.isEmpty()) {
+            return;
+        }
+
+        OutputStreamWriter osw = null;
+
+        try {
+            final JSONArray jsonSites = new JSONArray();
+            for (Site site : sites.values()) {
+                jsonSites.put(site.toJSON());
+            }
+
+            osw = new OutputStreamWriter(new FileOutputStream(f), "UTF-8");
+
+            final String jsonString = jsonSites.toString();
+            osw.write(jsonString, 0, jsonString.length());
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Failed to save suggested sites", e);
+        } finally {
+            if (osw != null) {
+                try {
+                    osw.close();
+                } catch (IOException e) {
+                    // Ignore.
+                }
+            }
+        }
+    }
+
+    private void maybeWaitForDistribution() {
+        if (distribution == null) {
+            return;
+        }
+
+        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+            @Override
+            public void distributionNotFound() {
+                // If distribution doesn't exist, simply continue to load
+                // suggested sites directly from resources. See refresh().
+            }
+
+            @Override
+            public void distributionFound(Distribution distribution) {
+                Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
+                // Merge suggested sites from distribution with the
+                // default ones. Distribution takes precedence.
+                Map<String, Site> sites = loadFromDistribution(distribution);
+                if (sites == null) {
+                    sites = new LinkedHashMap<String, Site>();
+                }
+                sites.putAll(loadFromResource());
+
+                // Update cached list of sites.
+                setCachedSites(sites);
+
+                // Save the result to disk.
+                final File file = getFile();
+                synchronized (file) {
+                    saveSites(file, sites);
+                }
+
+                // Then notify any active loaders about the changes.
+                final ContentResolver cr = context.getContentResolver();
+                cr.notifyChange(BrowserContract.SuggestedSites.CONTENT_URI, null);
+            }
+
+            @Override
+            public void distributionArrivedLate(Distribution distribution) {
+                distributionFound(distribution);
+            }
+        });
+    }
+
+    /**
+     * Loads suggested sites from a distribution file either matching the
+     * current locale or with the fallback locale (en-US).
+     *
+     * It's assumed that the given distribution instance is ready to be
+     * used and exists.
+     */
+    static Map<String, Site> loadFromDistribution(Distribution dist) {
+        for (Locale locale : getAcceptableLocales()) {
+            try {
+                final String languageTag = Locales.getLanguageTag(locale);
+                final String path = String.format("suggestedsites/locales/%s/%s",
+                                                  languageTag, FILENAME);
+
+                final File f = dist.getDistributionFile(path);
+                if (f == null) {
+                    Log.d(LOGTAG, "No suggested sites for locale: " + languageTag);
+                    continue;
+                }
+
+                return loadSites(f);
+            } catch (Exception e) {
+                Log.e(LOGTAG, "Failed to open suggested sites for locale " +
+                              locale + " in distribution.", e);
+            }
+        }
+
+        return null;
+    }
+
+    private Map<String, Site> loadFromProfile() {
+        try {
+            final File file = getFile();
+            synchronized (file) {
+                return loadSites(file);
+            }
+        } catch (FileNotFoundException e) {
+            maybeWaitForDistribution();
+        } catch (IOException e) {
+            // Fall through, return null.
+        }
+
+        return null;
+    }
+
+    Map<String, Site> loadFromResource() {
+        try {
+            return loadSites(RawResource.getAsString(context, R.raw.suggestedsites));
+        } catch (IOException e) {
+            return null;
+        }
+    }
+
+    private synchronized void setCachedSites(Map<String, Site> sites) {
+        cachedSites = Collections.unmodifiableMap(sites);
+        updateSuggestedSitesLocale(context);
+    }
+
+    /**
+     * Refreshes the cached list of sites either from the default raw
+     * source or standard file location. This will be called on every
+     * cache miss during a {@code get()} call.
+     */
+    private void refresh() {
+        Log.d(LOGTAG, "Refreshing suggested sites from file");
+
+        Map<String, Site> sites = loadFromProfile();
+        if (sites == null) {
+            sites = loadFromResource();
+        }
+
+        // Update cached list of sites.
+        if (sites != null) {
+            setCachedSites(sites);
+        }
+    }
+
+    private static void updateSuggestedSitesLocale(Context context) {
+        final Editor editor = GeckoSharedPrefs.forProfile(context).edit();
+        editor.putString(PREF_SUGGESTED_SITES_LOCALE, Locale.getDefault().toString());
+        editor.apply();
+    }
+
+    private boolean isEnabled() {
+        final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+        return prefs.getBoolean(GeckoPreferences.PREFS_SUGGESTED_SITES, true);
+    }
+
+    private synchronized Site getSiteForUrl(String url) {
+        if (cachedSites == null) {
+            return null;
+        }
+
+        return cachedSites.get(url);
+    }
+
+    /**
+     * Returns a {@code Cursor} with the list of suggested websites.
+     *
+     * @param limit maximum number of suggested sites.
+     */
+    @Override
+    public Cursor get(int limit) {
+        return get(limit, Locale.getDefault());
+    }
+
+    /**
+     * Returns a {@code Cursor} with the list of suggested websites.
+     *
+     * @param limit maximum number of suggested sites.
+     * @param locale the target locale.
+     */
+    @Override
+    public Cursor get(int limit, Locale locale) {
+        return get(limit, locale, null);
+    }
+
+    /**
+     * Returns a {@code Cursor} with the list of suggested websites.
+     *
+     * @param limit maximum number of suggested sites.
+     * @param excludeUrls list of URLs to be excluded from the list.
+     */
+    @Override
+    public Cursor get(int limit, List<String> excludeUrls) {
+        return get(limit, Locale.getDefault(), excludeUrls);
+    }
+
+    /**
+     * Returns a {@code Cursor} with the list of suggested websites.
+     *
+     * @param limit maximum number of suggested sites.
+     * @param locale the target locale.
+     * @param excludeUrls list of URLs to be excluded from the list.
+     */
+    @Override
+    public synchronized Cursor get(int limit, Locale locale, List<String> excludeUrls) {
+        final MatrixCursor cursor = new MatrixCursor(COLUMNS);
+
+        // Return an empty cursor if suggested sites have been
+        // disabled by the user.
+        if (!isEnabled()) {
+            return cursor;
+        }
+
+        final boolean isNewLocale = isNewLocale(context, locale);
+
+        // Force the suggested sites file in profile dir to be re-generated
+        // if the locale has changed.
+        if (isNewLocale) {
+            getFile().delete();
+        }
+
+        if (cachedSites == null || isNewLocale) {
+            Log.d(LOGTAG, "No cached sites, refreshing.");
+            refresh();
+        }
+
+        // Return empty cursor if there was an error when
+        // loading the suggested sites or the list is empty.
+        if (cachedSites == null || cachedSites.isEmpty()) {
+            return cursor;
+        }
+
+        excludeUrls = includeBlacklist(excludeUrls);
+
+        final int sitesCount = cachedSites.size();
+        Log.d(LOGTAG, "Number of suggested sites: " + sitesCount);
+
+        final int maxCount = Math.min(limit, sitesCount);
+        for (Site site : cachedSites.values()) {
+            if (cursor.getCount() == maxCount) {
+                break;
+            }
+
+            if (excludeUrls != null && excludeUrls.contains(site.url)) {
+                continue;
+            }
+
+            final boolean restrictedProfile =  Restrictions.isRestrictedProfile(context);
+
+            if (restrictedProfile == site.restricted) {
+                final RowBuilder row = cursor.newRow();
+                row.add(-1);
+                row.add(site.url);
+                row.add(site.title);
+            }
+        }
+
+        cursor.setNotificationUri(context.getContentResolver(),
+                                  BrowserContract.SuggestedSites.CONTENT_URI);
+
+        return cursor;
+    }
+
+    @Override
+    public boolean contains(String url) {
+        return (getSiteForUrl(url) != null);
+    }
+
+    @Override
+    public String getImageUrlForUrl(String url) {
+        final Site site = getSiteForUrl(url);
+        return (site != null ? site.imageUrl : null);
+    }
+
+    @Override
+    public String getBackgroundColorForUrl(String url) {
+        final Site site = getSiteForUrl(url);
+        return (site != null ? site.bgColor : null);
+    }
+
+    private Set<String> loadBlacklist() {
+        Log.d(LOGTAG, "Loading blacklisted suggested sites from SharedPreferences.");
+        final Set<String> blacklist = new HashSet<String>();
+
+        final SharedPreferences preferences = GeckoSharedPrefs.forProfile(context);
+        final String sitesString = preferences.getString(PREF_SUGGESTED_SITES_HIDDEN, null);
+
+        if (sitesString != null) {
+            for (String site : sitesString.trim().split(" ")) {
+                blacklist.add(Uri.decode(site));
+            }
+        }
+
+        return blacklist;
+    }
+
+    private List<String> includeBlacklist(List<String> originalList) {
+        if (cachedBlacklist == null) {
+            cachedBlacklist = loadBlacklist();
+        }
+
+        if (cachedBlacklist.isEmpty()) {
+            return originalList;
+        }
+
+        if (originalList == null) {
+            originalList = new ArrayList<String>();
+        }
+
+        originalList.addAll(cachedBlacklist);
+        return originalList;
+    }
+
+    /**
+     * Blacklist a suggested site so it will no longer be returned as a suggested site.
+     * This method should only be called from a background thread because it may write
+     * to SharedPreferences.
+     *
+     * Urls that are not Suggested Sites are ignored.
+     *
+     * @param url String url of site to blacklist
+     * @return true is blacklisted, false otherwise
+     */
+    @Override
+    public synchronized boolean hideSite(String url) {
+        ThreadUtils.assertNotOnUiThread();
+
+        if (cachedSites == null) {
+            refresh();
+            if (cachedSites == null) {
+                Log.w(LOGTAG, "Could not load suggested sites!");
+                return false;
+            }
+        }
+
+        if (cachedSites.containsKey(url)) {
+            if (cachedBlacklist == null) {
+                cachedBlacklist = loadBlacklist();
+            }
+
+            // Check if site has already been blacklisted, just in case.
+            if (!cachedBlacklist.contains(url)) {
+
+                saveToBlacklist(url);
+                cachedBlacklist.add(url);
+
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private void saveToBlacklist(String url) {
+        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+        final String prefString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, "");
+        final String siteString = prefString.concat(" " + Uri.encode(url));
+        prefs.edit().putString(PREF_SUGGESTED_SITES_HIDDEN, siteString).apply();
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
@@ -1,54 +1,23 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.db;
 
-import android.content.Context;
-import android.content.ContentResolver;
-import android.content.SharedPreferences;
-import android.content.SharedPreferences.Editor;
 import android.database.Cursor;
-import android.database.MatrixCursor;
-import android.database.MatrixCursor.RowBuilder;
-import android.net.Uri;
 import android.text.TextUtils;
-import android.util.Log;
 
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
+import org.json.JSONException;
+import org.json.JSONObject;
+
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Set;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.mozilla.gecko.annotation.RobocopTarget;
-import org.mozilla.gecko.GeckoSharedPrefs;
-import org.mozilla.gecko.GeckoProfile;
-import org.mozilla.gecko.Locales;
-import org.mozilla.gecko.R;
-import org.mozilla.gecko.distribution.Distribution;
-import org.mozilla.gecko.Restrictions;
-import org.mozilla.gecko.preferences.GeckoPreferences;
-import org.mozilla.gecko.util.RawResource;
-import org.mozilla.gecko.util.ThreadUtils;
 
 /**
  * {@code SuggestedSites} provides API to get a list of locale-specific
  * suggested sites to be used in Fennec's top sites panel. It provides
  * only a single method to fetch the list as a {@code Cursor}. This cursor
  * will then be wrapped by {@code TopSitesCursorWrapper} to blend top,
  * pinned, and suggested sites in the UI. The returned {@code Cursor}
  * uses its own schema defined in {@code BrowserContract.SuggestedSites}
@@ -59,42 +28,45 @@ import org.mozilla.gecko.util.ThreadUtil
  * {@code get()} call.
  *
  * The default list of suggested sites is stored in a raw Android
  * resource ({@code R.raw.suggestedsites}) which is dynamically
  * generated at build time for each target locale.
  *
  * Changes to the list of suggested sites are saved in SharedPreferences.
  */
-@RobocopTarget
-public class SuggestedSites {
-    private static final String LOGTAG = "GeckoSuggestedSites";
-
+public interface SuggestedSites {
     // SharedPreference key for suggested sites that should be hidden.
-    public static final String PREF_SUGGESTED_SITES_HIDDEN = "suggestedSites.hidden";
-
+    String PREF_SUGGESTED_SITES_HIDDEN = "suggestedSites.hidden";
     // Locale used to generate the current suggested sites.
-    public static final String PREF_SUGGESTED_SITES_LOCALE = "suggestedSites.locale";
+    String PREF_SUGGESTED_SITES_LOCALE = "suggestedSites.locale";
+
+    Cursor get(int limit);
 
-    // File in profile dir with the list of suggested sites.
-    private static final String FILENAME = "suggestedsites.json";
+    Cursor get(int limit, Locale locale);
+
+    Cursor get(int limit, List<String> excludeUrls);
+
+    Cursor get(int limit, Locale locale, List<String> excludeUrls);
 
-    private static final String[] COLUMNS = new String[] {
-        BrowserContract.SuggestedSites._ID,
-        BrowserContract.SuggestedSites.URL,
-        BrowserContract.SuggestedSites.TITLE,
-    };
+    boolean contains(String url);
+
+    String getImageUrlForUrl(String url);
+
+    String getBackgroundColorForUrl(String url);
+
+    boolean hideSite(String url);
 
-    private static final String JSON_KEY_URL = "url";
-    private static final String JSON_KEY_TITLE = "title";
-    private static final String JSON_KEY_IMAGE_URL = "imageurl";
-    private static final String JSON_KEY_BG_COLOR = "bgcolor";
-    private static final String JSON_KEY_RESTRICTED = "restricted";
+    static final String JSON_KEY_URL = "url";
+    static final String JSON_KEY_TITLE = "title";
+    static final String JSON_KEY_IMAGE_URL = "imageurl";
+    static final String JSON_KEY_BG_COLOR = "bgcolor";
+    static final String JSON_KEY_RESTRICTED = "restricted";
 
-    private static class Site {
+    public static class Site {
         public final String url;
         public final String title;
         public final String imageUrl;
         public final String bgColor;
         public final boolean restricted;
 
         public Site(JSONObject json) throws JSONException {
             this.restricted =  !json.isNull(JSON_KEY_RESTRICTED);
@@ -146,466 +118,9 @@ public class SuggestedSites {
             json.put(JSON_KEY_URL, url);
             json.put(JSON_KEY_TITLE, title);
             json.put(JSON_KEY_IMAGE_URL, imageUrl);
             json.put(JSON_KEY_BG_COLOR, bgColor);
 
             return json;
         }
     }
-
-    final Context context;
-    final Distribution distribution;
-    private File cachedFile;
-    private Map<String, Site> cachedSites;
-    private Set<String> cachedBlacklist;
-
-    public SuggestedSites(Context appContext) {
-        this(appContext, null);
-    }
-
-    public SuggestedSites(Context appContext, Distribution distribution) {
-        this(appContext, distribution, null);
-    }
-
-    public SuggestedSites(Context appContext, Distribution distribution, File file) {
-        this.context = appContext;
-        this.distribution = distribution;
-        this.cachedFile = file;
-    }
-
-    synchronized File getFile() {
-        if (cachedFile == null) {
-            cachedFile = GeckoProfile.get(context).getFile(FILENAME);
-        }
-        return cachedFile;
-    }
-
-    private static boolean isNewLocale(Context context, Locale requestedLocale) {
-        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
-
-        String locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE, null);
-        if (locale == null) {
-            // Initialize config with the current locale
-            updateSuggestedSitesLocale(context);
-            return true;
-        }
-
-        return !TextUtils.equals(requestedLocale.toString(), locale);
-    }
-
-    /**
-     * Return the current locale and its fallback (en_US) in order.
-     */
-    private static List<Locale> getAcceptableLocales() {
-        final List<Locale> locales = new ArrayList<Locale>();
-
-        final Locale defaultLocale = Locale.getDefault();
-        locales.add(defaultLocale);
-
-        if (!defaultLocale.equals(Locale.US)) {
-            locales.add(Locale.US);
-        }
-
-        return locales;
-    }
-
-    private static Map<String, Site> loadSites(File f) throws IOException {
-        Scanner scanner = null;
-
-        try {
-            scanner = new Scanner(f, "UTF-8");
-            return loadSites(scanner.useDelimiter("\\A").next());
-        } finally {
-            if (scanner != null) {
-                scanner.close();
-            }
-        }
-    }
-
-    private static Map<String, Site> loadSites(String jsonString) {
-        if (TextUtils.isEmpty(jsonString)) {
-            return null;
-        }
-
-        Map<String, Site> sites = null;
-
-        try {
-            final JSONArray jsonSites = new JSONArray(jsonString);
-            sites = new LinkedHashMap<String, Site>(jsonSites.length());
-
-            final int count = jsonSites.length();
-            for (int i = 0; i < count; i++) {
-                final Site site = new Site(jsonSites.getJSONObject(i));
-                sites.put(site.url, site);
-            }
-        } catch (Exception e) {
-            Log.e(LOGTAG, "Failed to refresh suggested sites", e);
-            return null;
-        }
-
-        return sites;
-    }
-
-    /**
-     * Saves suggested sites file to disk. Access to this method should
-     * be synchronized on 'file'.
-     */
-    static void saveSites(File f, Map<String, Site> sites) {
-        ThreadUtils.assertNotOnUiThread();
-
-        if (sites == null || sites.isEmpty()) {
-            return;
-        }
-
-        OutputStreamWriter osw = null;
-
-        try {
-            final JSONArray jsonSites = new JSONArray();
-            for (Site site : sites.values()) {
-                jsonSites.put(site.toJSON());
-            }
-
-            osw = new OutputStreamWriter(new FileOutputStream(f), "UTF-8");
-
-            final String jsonString = jsonSites.toString();
-            osw.write(jsonString, 0, jsonString.length());
-        } catch (Exception e) {
-            Log.e(LOGTAG, "Failed to save suggested sites", e);
-        } finally {
-            if (osw != null) {
-                try {
-                    osw.close();
-                } catch (IOException e) {
-                    // Ignore.
-                }
-            }
-        }
-    }
-
-    private void maybeWaitForDistribution() {
-        if (distribution == null) {
-            return;
-        }
-
-        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
-            @Override
-            public void distributionNotFound() {
-                // If distribution doesn't exist, simply continue to load
-                // suggested sites directly from resources. See refresh().
-            }
-
-            @Override
-            public void distributionFound(Distribution distribution) {
-                Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
-                // Merge suggested sites from distribution with the
-                // default ones. Distribution takes precedence.
-                Map<String, Site> sites = loadFromDistribution(distribution);
-                if (sites == null) {
-                    sites = new LinkedHashMap<String, Site>();
-                }
-                sites.putAll(loadFromResource());
-
-                // Update cached list of sites.
-                setCachedSites(sites);
-
-                // Save the result to disk.
-                final File file = getFile();
-                synchronized (file) {
-                    saveSites(file, sites);
-                }
-
-                // Then notify any active loaders about the changes.
-                final ContentResolver cr = context.getContentResolver();
-                cr.notifyChange(BrowserContract.SuggestedSites.CONTENT_URI, null);
-            }
-
-            @Override
-            public void distributionArrivedLate(Distribution distribution) {
-                distributionFound(distribution);
-            }
-        });
-    }
-
-    /**
-     * Loads suggested sites from a distribution file either matching the
-     * current locale or with the fallback locale (en-US).
-     *
-     * It's assumed that the given distribution instance is ready to be
-     * used and exists.
-     */
-    static Map<String, Site> loadFromDistribution(Distribution dist) {
-        for (Locale locale : getAcceptableLocales()) {
-            try {
-                final String languageTag = Locales.getLanguageTag(locale);
-                final String path = String.format("suggestedsites/locales/%s/%s",
-                                                  languageTag, FILENAME);
-
-                final File f = dist.getDistributionFile(path);
-                if (f == null) {
-                    Log.d(LOGTAG, "No suggested sites for locale: " + languageTag);
-                    continue;
-                }
-
-                return loadSites(f);
-            } catch (Exception e) {
-                Log.e(LOGTAG, "Failed to open suggested sites for locale " +
-                              locale + " in distribution.", e);
-            }
-        }
-
-        return null;
-    }
-
-    private Map<String, Site> loadFromProfile() {
-        try {
-            final File file = getFile();
-            synchronized (file) {
-                return loadSites(file);
-            }
-        } catch (FileNotFoundException e) {
-            maybeWaitForDistribution();
-        } catch (IOException e) {
-            // Fall through, return null.
-        }
-
-        return null;
-    }
-
-    Map<String, Site> loadFromResource() {
-        try {
-            return loadSites(RawResource.getAsString(context, R.raw.suggestedsites));
-        } catch (IOException e) {
-            return null;
-        }
-    }
-
-    private synchronized void setCachedSites(Map<String, Site> sites) {
-        cachedSites = Collections.unmodifiableMap(sites);
-        updateSuggestedSitesLocale(context);
-    }
-
-    /**
-     * Refreshes the cached list of sites either from the default raw
-     * source or standard file location. This will be called on every
-     * cache miss during a {@code get()} call.
-     */
-    private void refresh() {
-        Log.d(LOGTAG, "Refreshing suggested sites from file");
-
-        Map<String, Site> sites = loadFromProfile();
-        if (sites == null) {
-            sites = loadFromResource();
-        }
-
-        // Update cached list of sites.
-        if (sites != null) {
-            setCachedSites(sites);
-        }
-    }
-
-    private static void updateSuggestedSitesLocale(Context context) {
-        final Editor editor = GeckoSharedPrefs.forProfile(context).edit();
-        editor.putString(PREF_SUGGESTED_SITES_LOCALE, Locale.getDefault().toString());
-        editor.apply();
-    }
-
-    private boolean isEnabled() {
-        final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
-        return prefs.getBoolean(GeckoPreferences.PREFS_SUGGESTED_SITES, true);
-    }
-
-    private synchronized Site getSiteForUrl(String url) {
-        if (cachedSites == null) {
-            return null;
-        }
-
-        return cachedSites.get(url);
-    }
-
-    /**
-     * Returns a {@code Cursor} with the list of suggested websites.
-     *
-     * @param limit maximum number of suggested sites.
-     */
-    public Cursor get(int limit) {
-        return get(limit, Locale.getDefault());
-    }
-
-    /**
-     * Returns a {@code Cursor} with the list of suggested websites.
-     *
-     * @param limit maximum number of suggested sites.
-     * @param locale the target locale.
-     */
-    public Cursor get(int limit, Locale locale) {
-        return get(limit, locale, null);
-    }
-
-    /**
-     * Returns a {@code Cursor} with the list of suggested websites.
-     *
-     * @param limit maximum number of suggested sites.
-     * @param excludeUrls list of URLs to be excluded from the list.
-     */
-    public Cursor get(int limit, List<String> excludeUrls) {
-        return get(limit, Locale.getDefault(), excludeUrls);
-    }
-
-    /**
-     * Returns a {@code Cursor} with the list of suggested websites.
-     *
-     * @param limit maximum number of suggested sites.
-     * @param locale the target locale.
-     * @param excludeUrls list of URLs to be excluded from the list.
-     */
-    public synchronized Cursor get(int limit, Locale locale, List<String> excludeUrls) {
-        final MatrixCursor cursor = new MatrixCursor(COLUMNS);
-
-        // Return an empty cursor if suggested sites have been
-        // disabled by the user.
-        if (!isEnabled()) {
-            return cursor;
-        }
-
-        final boolean isNewLocale = isNewLocale(context, locale);
-
-        // Force the suggested sites file in profile dir to be re-generated
-        // if the locale has changed.
-        if (isNewLocale) {
-            getFile().delete();
-        }
-
-        if (cachedSites == null || isNewLocale) {
-            Log.d(LOGTAG, "No cached sites, refreshing.");
-            refresh();
-        }
-
-        // Return empty cursor if there was an error when
-        // loading the suggested sites or the list is empty.
-        if (cachedSites == null || cachedSites.isEmpty()) {
-            return cursor;
-        }
-
-        excludeUrls = includeBlacklist(excludeUrls);
-
-        final int sitesCount = cachedSites.size();
-        Log.d(LOGTAG, "Number of suggested sites: " + sitesCount);
-
-        final int maxCount = Math.min(limit, sitesCount);
-        for (Site site : cachedSites.values()) {
-            if (cursor.getCount() == maxCount) {
-                break;
-            }
-
-            if (excludeUrls != null && excludeUrls.contains(site.url)) {
-                continue;
-            }
-
-            final boolean restrictedProfile =  Restrictions.isRestrictedProfile(context);
-
-            if (restrictedProfile == site.restricted) {
-                final RowBuilder row = cursor.newRow();
-                row.add(-1);
-                row.add(site.url);
-                row.add(site.title);
-            }
-        }
-
-        cursor.setNotificationUri(context.getContentResolver(),
-                                  BrowserContract.SuggestedSites.CONTENT_URI);
-
-        return cursor;
-    }
-
-    public boolean contains(String url) {
-        return (getSiteForUrl(url) != null);
-    }
-
-    public String getImageUrlForUrl(String url) {
-        final Site site = getSiteForUrl(url);
-        return (site != null ? site.imageUrl : null);
-    }
-
-    public String getBackgroundColorForUrl(String url) {
-        final Site site = getSiteForUrl(url);
-        return (site != null ? site.bgColor : null);
-    }
-
-    private Set<String> loadBlacklist() {
-        Log.d(LOGTAG, "Loading blacklisted suggested sites from SharedPreferences.");
-        final Set<String> blacklist = new HashSet<String>();
-
-        final SharedPreferences preferences = GeckoSharedPrefs.forProfile(context);
-        final String sitesString = preferences.getString(PREF_SUGGESTED_SITES_HIDDEN, null);
-
-        if (sitesString != null) {
-            for (String site : sitesString.trim().split(" ")) {
-                blacklist.add(Uri.decode(site));
-            }
-        }
-
-        return blacklist;
-    }
-
-    private List<String> includeBlacklist(List<String> originalList) {
-        if (cachedBlacklist == null) {
-            cachedBlacklist = loadBlacklist();
-        }
-
-        if (cachedBlacklist.isEmpty()) {
-            return originalList;
-        }
-
-        if (originalList == null) {
-            originalList = new ArrayList<String>();
-        }
-
-        originalList.addAll(cachedBlacklist);
-        return originalList;
-    }
-
-    /**
-     * Blacklist a suggested site so it will no longer be returned as a suggested site.
-     * This method should only be called from a background thread because it may write
-     * to SharedPreferences.
-     *
-     * Urls that are not Suggested Sites are ignored.
-     *
-     * @param url String url of site to blacklist
-     * @return true is blacklisted, false otherwise
-     */
-    public synchronized boolean hideSite(String url) {
-        ThreadUtils.assertNotOnUiThread();
-
-        if (cachedSites == null) {
-            refresh();
-            if (cachedSites == null) {
-                Log.w(LOGTAG, "Could not load suggested sites!");
-                return false;
-            }
-        }
-
-        if (cachedSites.containsKey(url)) {
-            if (cachedBlacklist == null) {
-                cachedBlacklist = loadBlacklist();
-            }
-
-            // Check if site has already been blacklisted, just in case.
-            if (!cachedBlacklist.contains(url)) {
-
-                saveToBlacklist(url);
-                cachedBlacklist.add(url);
-
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    private void saveToBlacklist(String url) {
-        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
-        final String prefString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, "");
-        final String siteString = prefString.concat(" " + Uri.encode(url));
-        prefs.edit().putString(PREF_SUGGESTED_SITES_HIDDEN, siteString).apply();
-    }
 }
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/db/TestTopSites.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/db/TestTopSites.java
@@ -2,16 +2,17 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.background.db;
 
 
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.LocalSuggestedSites;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.sync.setup.Constants;
 
 import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
@@ -29,20 +30,17 @@ public class TestTopSites extends Activi
 
     public TestTopSites() {
         super(Activity.class);
     }
 
     @Override
     public void setUp() {
         mContext = getInstrumentation().getTargetContext();
-        mSuggestedSites = new SuggestedSites(mContext);
-
-        // By default we're using StubBrowserDB which has no suggested sites available.
-        GeckoProfile.get(mContext, Constants.DEFAULT_PROFILE).getDB().setSuggestedSites(mSuggestedSites);
+        mSuggestedSites = new LocalSuggestedSites(mContext);
     }
 
     @Override
     public void tearDown() {
         GeckoProfile.get(mContext, Constants.DEFAULT_PROFILE).getDB().setSuggestedSites(null);
     }
 
     public void testGetTopSites() {
--- a/mobile/android/tests/browser/junit3/src/org/mozilla/tests/browser/junit3/TestSuggestedSites.java
+++ b/mobile/android/tests/browser/junit3/src/org/mozilla/tests/browser/junit3/TestSuggestedSites.java
@@ -15,16 +15,17 @@ import android.test.InstrumentationTestC
 import android.test.RenamingDelegatingContext;
 import android.test.mock.MockResources;
 import org.json.JSONArray;
 import org.json.JSONObject;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.LocalSuggestedSites;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.preferences.GeckoPreferences;
 
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -196,17 +197,17 @@ public class TestSuggestedSites extends 
     }
 
     private void checkCursorCount(String content, int expectedCount) {
         checkCursorCount(content, expectedCount, DEFAULT_LIMIT);
     }
 
     private void checkCursorCount(String content, int expectedCount, int limit) {
         resources.setSuggestedSitesResource(content);
-        Cursor c = new SuggestedSites(context).get(limit);
+        Cursor c = new LocalSuggestedSites(context).get(limit);
         assertEquals(expectedCount, c.getCount());
         c.close();
     }
 
     protected void setUp() {
         context = new TestContext(getInstrumentation().getTargetContext());
         resources = (TestResources) context.getResources();
         tempFiles = new ArrayList<File>();
@@ -239,17 +240,17 @@ public class TestSuggestedSites extends 
 
         // Invalid json string = empty cursor
         checkCursorCount("{ broken: }", 0);
     }
 
     public void testCursorContent() {
         resources.setSuggestedSitesResource(generateSites(3));
 
-        Cursor c = new SuggestedSites(context).get(DEFAULT_LIMIT);
+        Cursor c = new LocalSuggestedSites(context).get(DEFAULT_LIMIT);
         assertEquals(3, c.getCount());
 
         c.moveToPosition(-1);
         while (c.moveToNext()) {
             int position = c.getPosition();
 
             String url = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.URL));
             assertEquals("url" + position, url);
@@ -269,17 +270,17 @@ public class TestSuggestedSites extends 
         excludedUrls.add("url3");
         excludedUrls.add("url5");
 
         List<String> includedUrls = new ArrayList<String>(3);
         includedUrls.add("url0");
         includedUrls.add("url2");
         includedUrls.add("url4");
 
-        Cursor c = new SuggestedSites(context).get(DEFAULT_LIMIT, excludedUrls);
+        Cursor c = new LocalSuggestedSites(context).get(DEFAULT_LIMIT, excludedUrls);
 
         c.moveToPosition(-1);
         while (c.moveToNext()) {
             String url = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.URL));
             assertFalse(excludedUrls.contains(url));
             assertTrue(includedUrls.contains(url));
         }
 
@@ -306,52 +307,52 @@ public class TestSuggestedSites extends 
             hiddenUrlBuilder.append(Uri.encode(s));
         }
 
         final String hiddenPref = hiddenUrlBuilder.toString();
         GeckoSharedPrefs.forProfile(context).edit()
                                         .putString(SuggestedSites.PREF_SUGGESTED_SITES_HIDDEN, hiddenPref)
                                         .commit();
 
-        Cursor c = new SuggestedSites(context).get(DEFAULT_LIMIT);
+        Cursor c = new LocalSuggestedSites(context).get(DEFAULT_LIMIT);
         assertEquals(Math.min(3, DEFAULT_LIMIT), c.getCount());
 
         c.moveToPosition(-1);
         while (c.moveToNext()) {
             String url = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.URL));
             assertFalse(hiddenUrls.contains(url));
             assertTrue(visibleUrls.contains(url));
         }
 
         c.close();
     }
 
     public void testDisabledState() {
         resources.setSuggestedSitesResource(generateSites(3));
 
-        Cursor c = new SuggestedSites(context).get(DEFAULT_LIMIT);
+        Cursor c = new LocalSuggestedSites(context).get(DEFAULT_LIMIT);
         assertEquals(3, c.getCount());
         c.close();
 
         // Disable suggested sites
         GeckoSharedPrefs.forApp(context).edit()
                                         .putBoolean(GeckoPreferences.PREFS_SUGGESTED_SITES, false)
                                         .commit();
 
-        c = new SuggestedSites(context).get(DEFAULT_LIMIT);
+        c = new LocalSuggestedSites(context).get(DEFAULT_LIMIT);
         assertNotNull(c);
         assertEquals(0, c.getCount());
         c.close();
     }
 
     public void testImageUrlAndBgColor() {
         final int count = 3;
         resources.setSuggestedSitesResource(generateSites(count));
 
-        SuggestedSites suggestedSites = new SuggestedSites(context);
+        SuggestedSites suggestedSites = new LocalSuggestedSites(context);
 
         // Suggested sites hasn't been loaded yet.
         for (int i = 0; i < count; i++) {
             String url = "url" + i;
             assertFalse(suggestedSites.contains(url));
             assertNull(suggestedSites.getImageUrlForUrl(url));
             assertNull(suggestedSites.getBackgroundColorForUrl(url));
         }
@@ -374,17 +375,17 @@ public class TestSuggestedSites extends 
         assertFalse(suggestedSites.contains("foo"));
         assertNull(suggestedSites.getImageUrlForUrl("foo"));
         assertNull(suggestedSites.getBackgroundColorForUrl("foo"));
     }
 
     public void testLocaleChanges() {
         resources.setSuggestedSitesResource(generateSites(3));
 
-        SuggestedSites suggestedSites = new SuggestedSites(context);
+        SuggestedSites suggestedSites = new LocalSuggestedSites(context);
 
         // Initial load with predefined locale
         Cursor c = suggestedSites.get(DEFAULT_LIMIT, Locale.UK);
         assertEquals(3, c.getCount());
         c.close();
 
         resources.setSuggestedSitesResource(generateSites(5));
 
@@ -424,17 +425,17 @@ public class TestSuggestedSites extends 
         // Init distribution with the mock file.
         TestDistribution distribution = new TestDistribution(context);
         distribution.setFileForLocale(Locale.getDefault(), distFile);
         distribution.start();
 
         // Init suggested sites with default values.
         resources.setSuggestedSitesResource(generateSites(DEFAULT_COUNT));
         SuggestedSites suggestedSites =
-                new SuggestedSites(context, distribution, sitesFile);
+                new LocalSuggestedSites(context, distribution, sitesFile);
 
         // The initial query will not contain the distribution sites
         // yet. This will happen asynchronously once the distribution
         // is installed.
         Cursor c1 = null;
         try {
             c1 = suggestedSites.get(DEFAULT_LIMIT);
             assertEquals(DEFAULT_COUNT, c1.getCount());