Bug 1241810 - Add JSON-based storage for feed subscriptions. r?mcomella
MozReview-Commit-ID: 3qeI2wcSQpF
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
@@ -0,0 +1,161 @@
+/* -*- 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.feeds.subscriptions;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.feeds.FeedFetcher;
+import org.mozilla.gecko.feeds.parser.Item;
+
+/**
+ * An object describing a subscription and containing some meta data about the last time we fetched
+ * the feed.
+ */
+public class FeedSubscription {
+ private static final String JSON_KEY_FEED_URL = "feed_url";
+ private static final String JSON_KEY_FEED_TITLE = "feed_title";
+ private static final String JSON_KEY_WEBSITE_URL = "website_url";
+ private static final String JSON_KEY_LAST_ITEM_TITLE = "last_item_title";
+ private static final String JSON_KEY_LAST_ITEM_URL = "last_item_url";
+ private static final String JSON_KEY_LAST_ITEM_TIMESTAMP = "last_item_timestamp";
+ private static final String JSON_KEY_ETAG = "etag";
+ private static final String JSON_KEY_LAST_MODIFIED = "lastModified";
+ private static final String JSON_KEY_BOOKMARK_GUID = "bookmark_guid";
+
+ private String bookmarkGuid; // Currently a subscription is linked to a bookmark
+ private String feedUrl;
+ private String feedTitle;
+ private String websiteUrl;
+ private String lastItemTitle;
+ private String lastItemUrl;
+ private long lastItemTimestamp;
+ private String etag;
+ private String lastModified;
+
+ public static FeedSubscription create(String bookmarkGuid, String url, FeedFetcher.FeedResponse response) {
+ FeedSubscription subscription = new FeedSubscription();
+ subscription.bookmarkGuid = bookmarkGuid;
+ subscription.feedUrl = url;
+
+ subscription.update(response);
+
+ return subscription;
+ }
+
+ public static FeedSubscription fromJSON(JSONObject object) throws JSONException {
+ FeedSubscription subscription = new FeedSubscription();
+
+ subscription.feedUrl = object.getString(JSON_KEY_FEED_URL);
+ subscription.feedTitle = object.getString(JSON_KEY_FEED_TITLE);
+ subscription.websiteUrl = object.getString(JSON_KEY_WEBSITE_URL);
+ subscription.lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE);
+ subscription.lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL);
+ subscription.lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP);
+ subscription.etag = object.getString(JSON_KEY_ETAG);
+ subscription.lastModified = object.getString(JSON_KEY_LAST_MODIFIED);
+ subscription.bookmarkGuid = object.getString(JSON_KEY_BOOKMARK_GUID);
+
+ return subscription;
+ }
+
+ /* package-private */ void update(FeedFetcher.FeedResponse response) {
+ final String feedUrl = response.feed.getFeedURL();
+ if (!TextUtils.isEmpty(feedUrl)) {
+ // Prefer to use the URL we get from the feed for further requests
+ this.feedUrl = feedUrl;
+ }
+
+ feedTitle = response.feed.getTitle();
+ websiteUrl = response.feed.getWebsiteURL();
+ lastItemTitle = response.feed.getLastItem().getTitle();
+ lastItemUrl = response.feed.getLastItem().getURL();
+ lastItemTimestamp = response.feed.getLastItem().getTimestamp();
+ etag = response.etag;
+ lastModified = response.lastModified;
+ }
+
+
+ /**
+ * Guesstimate if this response is a newer representation of the feed.
+ */
+ public boolean isNewer(FeedFetcher.FeedResponse response) {
+ final Item otherItem = response.feed.getLastItem();
+
+ if (lastItemTimestamp > otherItem.getTimestamp()) {
+ return true; // How to detect if this same item and it only has been updated?
+ }
+
+ if (lastItemTimestamp == otherItem.getTimestamp() &&
+ lastItemTimestamp != 0) {
+ return false;
+ }
+
+ if (lastItemUrl == null || !lastItemUrl.equals(otherItem.getURL())) {
+ // URL changed: Probably a different item
+ return true;
+ }
+
+ return false;
+ }
+
+ public String getFeedUrl() {
+ return feedUrl;
+ }
+
+ public String getFeedTitle() {
+ return feedTitle;
+ }
+
+ public String getWebsiteUrl() {
+ return websiteUrl;
+ }
+
+ public String getLastItemTitle() {
+ return lastItemTitle;
+ }
+
+ public String getLastItemUrl() {
+ return lastItemUrl;
+ }
+
+ public long getLastItemTimestamp() {
+ return lastItemTimestamp;
+ }
+
+ public String getETag() {
+ return etag;
+ }
+
+ public String getLastModified() {
+ return lastModified;
+ }
+
+ public String getBookmarkGUID() {
+ return bookmarkGuid;
+ }
+
+ public boolean isForTheSameBookmarkAs(FeedSubscription other) {
+ return TextUtils.equals(bookmarkGuid, other.bookmarkGuid);
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ JSONObject object = new JSONObject();
+
+ object.put(JSON_KEY_FEED_URL, feedUrl);
+ object.put(JSON_KEY_FEED_TITLE, feedTitle);
+ object.put(JSON_KEY_WEBSITE_URL, websiteUrl);
+ object.put(JSON_KEY_LAST_ITEM_TITLE, lastItemTitle);
+ object.put(JSON_KEY_LAST_ITEM_URL, lastItemUrl);
+ object.put(JSON_KEY_LAST_ITEM_TIMESTAMP, lastItemTimestamp);
+ object.put(JSON_KEY_ETAG, etag);
+ object.put(JSON_KEY_LAST_MODIFIED, lastModified);
+ object.put(JSON_KEY_BOOKMARK_GUID, bookmarkGuid);
+
+ return object;
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/SubscriptionStorage.java
@@ -0,0 +1,220 @@
+/* -*- 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.feeds.subscriptions;
+
+import android.content.Context;
+import android.support.v4.util.AtomicFile;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.feeds.FeedFetcher;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Storage for feed subscriptions. This is just using a plain JSON file on disk.
+ *
+ * TODO: Store this data in the url metadata tablet instead (See bug 1250707)
+ */
+public class SubscriptionStorage {
+ private static final String LOGTAG = "FeedStorage";
+ private static final String FILE_NAME = "feed_subscriptions";
+
+ private static final String JSON_KEY_SUBSCRIPTIONS = "subscriptions";
+
+ private final AtomicFile file; // Guarded by 'file'
+
+ private List<FeedSubscription> subscriptions;
+ private boolean hasLoadedSubscriptions;
+ private boolean hasChanged;
+
+ public SubscriptionStorage(Context context) {
+ this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
+
+ startLoadFromDisk();
+ }
+
+ // For injecting mocked AtomicFile objects during test
+ protected SubscriptionStorage(AtomicFile file) {
+ this.subscriptions = new ArrayList<>();
+ this.file = file;
+ }
+
+ public synchronized void addSubscription(FeedSubscription subscription) {
+ awaitLoadingSubscriptionsLocked();
+
+ subscriptions.add(subscription);
+ hasChanged = true;
+ }
+
+ public synchronized void removeSubscription(FeedSubscription subscription) {
+ awaitLoadingSubscriptionsLocked();
+
+ Iterator<FeedSubscription> iterator = subscriptions.iterator();
+ while (iterator.hasNext()) {
+ if (subscription.isForTheSameBookmarkAs(iterator.next())) {
+ iterator.remove();
+ hasChanged = true;
+ return;
+ }
+ }
+ }
+
+ public synchronized List<FeedSubscription> getSubscriptions() {
+ awaitLoadingSubscriptionsLocked();
+
+ return new ArrayList<>(subscriptions);
+ }
+
+ public synchronized void updateSubscription(FeedSubscription subscription, FeedFetcher.FeedResponse response) {
+ awaitLoadingSubscriptionsLocked();
+
+ subscription.update(response);
+
+ for (int i = 0; i < subscriptions.size(); i++) {
+ if (subscriptions.get(i).isForTheSameBookmarkAs(subscription)) {
+ subscriptions.set(i, subscription);
+ hasChanged = true;
+ return;
+ }
+ }
+ }
+
+ public synchronized boolean hasSubscriptionForBookmark(String guid) {
+ awaitLoadingSubscriptionsLocked();
+
+ for (int i = 0; i < subscriptions.size(); i++) {
+ if (TextUtils.equals(guid, subscriptions.get(i).getBookmarkGUID())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void awaitLoadingSubscriptionsLocked() {
+ while (!hasLoadedSubscriptions) {
+ try {
+ Log.v(LOGTAG, "Waiting for subscriptions to be loaded");
+
+ wait();
+ } catch (InterruptedException e) {
+ // Ignore
+ }
+ }
+ }
+
+ public void persistChanges() {
+ new Thread(LOGTAG + "-Persist") {
+ public void run() {
+ writeToDisk();
+ }
+ }.start();
+ }
+
+ private void startLoadFromDisk() {
+ new Thread(LOGTAG + "-Load") {
+ public void run() {
+ loadFromDisk();
+ }
+ }.start();
+ }
+
+ protected synchronized void loadFromDisk() {
+ Log.d(LOGTAG, "Loading from disk");
+
+ if (hasLoadedSubscriptions) {
+ return;
+ }
+
+ List<FeedSubscription> subscriptions = new ArrayList<>();
+
+ try {
+ JSONObject data;
+
+ synchronized (file) {
+ data = new JSONObject(new String(file.readFully(), "UTF-8"));
+ }
+
+ JSONArray array = data.getJSONArray(JSON_KEY_SUBSCRIPTIONS);
+ for (int i = 0; i < array.length(); i++) {
+ subscriptions.add(FeedSubscription.fromJSON(array.getJSONObject(i)));
+ }
+ } catch (FileNotFoundException e) {
+ Log.d(LOGTAG, "No subscriptions yet.");
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Unable to parse subscriptions JSON. Using empty list.", e);
+ } catch (UnsupportedEncodingException e) {
+ AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
+ error.initCause(e);
+ throw error;
+ } catch (IOException e) {
+ Log.d(LOGTAG, "Can't read subscriptions due to IOException", e);
+ }
+
+ onSubscriptionsLoaded(subscriptions);
+
+ notifyAll();
+
+ Log.d(LOGTAG, "Loaded " + subscriptions.size() + " elements");
+ }
+
+ protected void onSubscriptionsLoaded(List<FeedSubscription> subscriptions) {
+ this.subscriptions = subscriptions;
+ this.hasLoadedSubscriptions = true;
+ }
+
+ protected synchronized void writeToDisk() {
+ if (!hasChanged) {
+ Log.v(LOGTAG, "Not persisting: Subscriptions have not changed");
+ return;
+ }
+
+ Log.d(LOGTAG, "Writing to disk");
+
+ FileOutputStream outputStream = null;
+
+ synchronized (file) {
+ try {
+ outputStream = file.startWrite();
+
+ JSONArray array = new JSONArray();
+ for (FeedSubscription subscription : this.subscriptions) {
+ array.put(subscription.toJSON());
+ }
+
+ JSONObject catalog = new JSONObject();
+ catalog.put(JSON_KEY_SUBSCRIPTIONS, array);
+
+ outputStream.write(catalog.toString().getBytes("UTF-8"));
+
+ file.finishWrite(outputStream);
+
+ hasChanged = false;
+ } catch (UnsupportedEncodingException e) {
+ AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
+ error.initCause(e);
+ throw error;
+ } catch (IOException | JSONException e) {
+ Log.e(LOGTAG, "IOException during writing catalog", e);
+
+ if (outputStream != null) {
+ file.failWrite(outputStream);
+ }
+ }
+ }
+ }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -274,16 +274,18 @@ gbjar.sources += ['java/org/mozilla/geck
'favicons/Favicons.java',
'favicons/LoadFaviconTask.java',
'favicons/OnFaviconLoadedListener.java',
'favicons/RemoteFavicon.java',
'feeds/FeedFetcher.java',
'feeds/parser/Feed.java',
'feeds/parser/Item.java',
'feeds/parser/SimpleFeedParser.java',
+ 'feeds/subscriptions/FeedSubscription.java',
+ 'feeds/subscriptions/SubscriptionStorage.java',
'FilePicker.java',
'FilePickerResultHandler.java',
'FindInPageBar.java',
'firstrun/DataPanel.java',
'firstrun/FirstrunAnimationContainer.java',
'firstrun/FirstrunPager.java',
'firstrun/FirstrunPagerConfig.java',
'firstrun/FirstrunPanel.java',