diff --git a/mobile/android/base/db/BrowserContract.java b/mobile/android/base/db/BrowserContract.java index f1ebedd7c1be..eb94a0a98d77 100644 --- a/mobile/android/base/db/BrowserContract.java +++ b/mobile/android/base/db/BrowserContract.java @@ -429,4 +429,12 @@ public class BrowserContract { public static final String TYPE = "type"; } + + @RobocopTarget + public static final class SuggestedSites implements CommonColumns, URLColumns { + private SuggestedSites() {} + + public static final String IMAGE_URL = "image_url"; + public static final String BG_COLOR = "bg_color"; + } } diff --git a/mobile/android/base/db/SuggestedSites.java b/mobile/android/base/db/SuggestedSites.java new file mode 100644 index 000000000000..30653ce0b1a4 --- /dev/null +++ b/mobile/android/base/db/SuggestedSites.java @@ -0,0 +1,190 @@ +/* -*- 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.database.Cursor; +import android.database.MatrixCursor; +import android.database.MatrixCursor.RowBuilder; +import android.text.TextUtils; +import android.util.Log; + +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.util.Collections; +import java.util.List; +import java.util.ArrayList; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.mozglue.RobocopTarget; +import org.mozilla.gecko.util.RawResource; + +/** + * {@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} + * for clarity. + * + * Under the hood, {@code SuggestedSites} keeps reference to the + * parsed list of sites to avoid reparsing the JSON file on every + * {@code get()} call. This cached list is a soft reference and can + * garbage collected at any moment. + * + * 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. + */ +@RobocopTarget +public class SuggestedSites { + private static final String LOGTAG = "GeckoSuggestedSites"; + + private static final String[] COLUMNS = new String[] { + BrowserContract.SuggestedSites._ID, + BrowserContract.SuggestedSites.URL, + BrowserContract.SuggestedSites.TITLE, + BrowserContract.SuggestedSites.IMAGE_URL, + BrowserContract.SuggestedSites.BG_COLOR + }; + + 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 class Site { + public final String url; + public final String title; + public final String imageUrl; + public final String bgColor; + + public Site(String url, String title, String imageUrl, String bgColor) { + this.url = url; + this.title = title; + this.imageUrl = imageUrl; + this.bgColor = bgColor; + } + + @Override + public String toString() { + return "{ url = " + url + "\n" + + "title = " + title + "\n" + + "imageUrl = " + imageUrl + "\n" + + "bgColor = " + bgColor + " }"; + } + } + + private final Context context; + private SoftReference> cachedSites; + + public SuggestedSites(Context appContext) { + context = appContext; + cachedSites = new SoftReference>(null); + } + + private String loadFromFile() { + // Do nothing for now + return null; + } + + private String loadFromResource() { + try { + return RawResource.getAsString(context, R.raw.suggestedsites); + } catch (IOException e) { + return null; + } + } + + /** + * 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 List refresh() { + Log.d(LOGTAG, "Refreshing tiles from file"); + + String jsonString = loadFromFile(); + if (TextUtils.isEmpty(jsonString)) { + Log.d(LOGTAG, "No suggested sites file, loading from resource."); + jsonString = loadFromResource(); + } + + List sites = null; + + try { + final JSONArray jsonSites = new JSONArray(jsonString); + sites = new ArrayList(jsonSites.length()); + + final int count = jsonSites.length(); + for (int i = 0; i < count; i++) { + final JSONObject jsonSite = (JSONObject) jsonSites.get(i); + + final Site site = new Site(jsonSite.getString(JSON_KEY_URL), + jsonSite.getString(JSON_KEY_TITLE), + jsonSite.getString(JSON_KEY_IMAGE_URL), + jsonSite.getString(JSON_KEY_BG_COLOR)); + + sites.add(site); + } + + Log.d(LOGTAG, "Successfully parsed suggested sites."); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to refresh suggested sites", e); + return null; + } + + // Update cached list of sites + cachedSites = new SoftReference>(Collections.unmodifiableList(sites)); + + // Return the refreshed list + return sites; + } + + /** + * Returns a {@code Cursor} with the list of suggested websites. + * + * @param limit maximum number of suggested sites. + */ + public Cursor get(int limit) { + List sites = cachedSites.get(); + if (sites == null) { + Log.d(LOGTAG, "No cached sites, refreshing."); + sites = refresh(); + } + + final MatrixCursor cursor = new MatrixCursor(COLUMNS); + + // Return empty cursor if there was an error when + // loading the suggested sites or the list is empty. + if (sites == null || sites.isEmpty()) { + return cursor; + } + + final int sitesCount = sites.size(); + Log.d(LOGTAG, "Number of suggested sites: " + sitesCount); + + final int count = Math.min(limit, sitesCount); + for (int i = 0; i < count; i++) { + final Site site = sites.get(i); + + final RowBuilder row = cursor.newRow(); + row.add(-1); + row.add(site.url); + row.add(site.title); + row.add(site.imageUrl); + row.add(site.bgColor); + } + + return cursor; + } +} \ No newline at end of file diff --git a/mobile/android/base/moz.build b/mobile/android/base/moz.build index 37c097849352..0d92b6825368 100644 --- a/mobile/android/base/moz.build +++ b/mobile/android/base/moz.build @@ -142,6 +142,7 @@ gbjar.sources += [ 'db/ReadingListProvider.java', 'db/SharedBrowserDatabaseProvider.java', 'db/SQLiteBridgeContentProvider.java', + 'db/SuggestedSites.java', 'db/TabsProvider.java', 'db/TopSitesCursorWrapper.java', 'Distribution.java', diff --git a/mobile/android/tests/browser/junit3/moz.build b/mobile/android/tests/browser/junit3/moz.build index 166d47396cb6..7d81516888df 100644 --- a/mobile/android/tests/browser/junit3/moz.build +++ b/mobile/android/tests/browser/junit3/moz.build @@ -14,6 +14,7 @@ jar.sources += [ 'src/tests/TestGeckoSharedPrefs.java', 'src/tests/TestJarReader.java', 'src/tests/TestRawResource.java', + 'src/tests/TestSuggestedSites.java', 'src/tests/TestTopSitesCursorWrapper.java', ] jar.generated_sources = [] # None yet -- try to keep it this way. diff --git a/mobile/android/tests/browser/junit3/src/tests/TestSuggestedSites.java b/mobile/android/tests/browser/junit3/src/tests/TestSuggestedSites.java new file mode 100644 index 000000000000..5dce8924f68e --- /dev/null +++ b/mobile/android/tests/browser/junit3/src/tests/TestSuggestedSites.java @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.gecko.browser.tests; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.test.mock.MockContext; +import android.test.mock.MockResources; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.SuggestedSites; +import org.mozilla.gecko.util.RawResource; + +public class TestSuggestedSites extends BrowserTestCase { + private static class TestContext extends MockContext { + private final Resources resources; + + public TestContext() { + resources = new TestResources(); + } + + @Override + public Resources getResources() { + return resources; + } + } + + private static class TestResources extends MockResources { + private String suggestedSites; + + @Override + public InputStream openRawResource(int id) { + if (id == R.raw.suggestedsites && suggestedSites != null) { + return new ByteArrayInputStream(suggestedSites.getBytes()); + } + + return null; + } + + public void setSuggestedSitesResource(String suggestedSites) { + this.suggestedSites = suggestedSites; + } + } + + private static final int DEFAULT_LIMIT = 6; + + private TestContext context; + private TestResources resources; + + private String generateSites(int n) { + JSONArray sites = new JSONArray(); + + try { + for (int i = 0; i < n; i++) { + JSONObject site = new JSONObject(); + site.put("url", "url" + i); + site.put("title", "title" + i); + site.put("imageurl", "imageUrl" + i); + site.put("bgcolor", "bgColor" + i); + + sites.put(site); + } + } catch (Exception e) { + return ""; + } + + return sites.toString(); + } + + 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); + assertEquals(expectedCount, c.getCount()); + c.close(); + } + + protected void setUp() { + context = new TestContext(); + resources = (TestResources) context.getResources(); + } + + public void testCount() { + // Empty array = empty cursor + checkCursorCount(generateSites(0), 0); + + // 2 items = cursor with 2 rows + checkCursorCount(generateSites(2), 2); + + // 10 items with lower limit = cursor respects limit + checkCursorCount(generateSites(10), 3, 3); + } + + public void testEmptyCursor() { + // Null resource = empty cursor + checkCursorCount(null, 0); + + // Empty string = empty cursor + checkCursorCount("", 0); + + // Invalid json string = empty cursor + checkCursorCount("{ broken: }", 0); + } + + public void testCursorContent() { + resources.setSuggestedSitesResource(generateSites(3)); + + Cursor c = new SuggestedSites(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); + + String title = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.TITLE)); + assertEquals("title" + position, title); + + String imageUrl = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.IMAGE_URL)); + assertEquals("imageUrl" + position, imageUrl); + + String bgColor = c.getString(c.getColumnIndexOrThrow(BrowserContract.SuggestedSites.BG_COLOR)); + assertEquals("bgColor" + position, bgColor); + } + + c.close(); + } +}