Bug 1241810 - Add JSON-based storage for feed subscriptions. r?mcomella draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Wed, 24 Feb 2016 14:27:31 -0800
changeset 335785 90013e1fc4e7116bd3238f102be68fce6aa8d5da
parent 335784 68ce6b1b15fa375cdee6fe469b2c78799a2caa60
child 335786 ba7be9bbd3c13a4354e102106c58e3e4cd90f1cd
push id11875
push users.kaspari@gmail.com
push dateTue, 01 Mar 2016 15:14:45 +0000
reviewersmcomella
bugs1241810
milestone47.0a1
Bug 1241810 - Add JSON-based storage for feed subscriptions. r?mcomella MozReview-Commit-ID: 3qeI2wcSQpF
mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/SubscriptionStorage.java
mobile/android/base/moz.build
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',