Bug 1207714 - Part 2: Add storage for App-wide push state. r?rnewman draft
authorNick Alexander <nalexander@mozilla.com>
Wed, 02 Mar 2016 15:48:37 -0800
changeset 336295 bd71061e26bad26960dfaa5e3eaefb0cfd33939d
parent 336294 74dd6ab391fcd67dfcd2d5794edfad327e72b76e
child 336296 0ca118abbe831b931531706c468c84b0d3d5ad5d
push id12029
push usernalexander@mozilla.com
push dateThu, 03 Mar 2016 00:10:36 +0000
reviewersrnewman
bugs1207714
milestone47.0a1
Bug 1207714 - Part 2: Add storage for App-wide push state. r?rnewman MozReview-Commit-ID: GOP4F6N2Mht
mobile/android/base/java/org/mozilla/gecko/push/Fetched.java
mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java
mobile/android/base/java/org/mozilla/gecko/push/PushState.java
mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java
@@ -0,0 +1,71 @@
+/* -*- 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.push;
+
+import android.support.annotation.NonNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Pair a (String) value with a timestamp.  The timestamp is usually when the
+ * value was fetched from a remote service or when the value was locally
+ * generated.
+ *
+ * It's awkward to serialize generic values to JSON -- that requires lots of
+ * factory classes -- so we specialize to String instances.
+ */
+public class Fetched {
+    public final String value;
+    public final long timestamp;
+
+    public Fetched(String value, long timestamp) {
+        this.value = value;
+        this.timestamp = timestamp;
+    }
+
+    public static Fetched now(String value) {
+        return new Fetched(value, System.currentTimeMillis());
+    }
+
+    public static @NonNull Fetched fromJSONObject(@NonNull JSONObject json) {
+        final String value = json.optString("value", null);
+        final String timestampString = json.optString("timestamp", null);
+        final long timestamp = timestampString != null ? Long.valueOf(timestampString) : 0L;
+        return new Fetched(value, timestamp);
+    }
+
+    public JSONObject toJSONObject() throws JSONException {
+        final JSONObject jsonObject = new JSONObject();
+        if (value != null) {
+            jsonObject.put("value", value);
+        } else {
+            jsonObject.remove("value");
+        }
+        jsonObject.put("timestamp", Long.toString(timestamp));
+        return jsonObject;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        // Auto-generated.
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Fetched fetched = (Fetched) o;
+
+        if (timestamp != fetched.timestamp) return false;
+        return !(value != null ? !value.equals(fetched.value) : fetched.value != null);
+
+    }
+
+    @Override
+    public int hashCode() {
+        // Auto-generated.
+        int result = value != null ? value.hashCode() : 0;
+        result = 31 * result + (int) (timestamp ^ (timestamp >>> 32));
+        return result;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java
@@ -0,0 +1,126 @@
+/* -*- 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.push;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Represent an autopush User Agent registration.
+ * <p/>
+ * Such a registration associates an endpoint, optional debug flag, some Google
+ * Cloud Messaging data, and the returned uaid and secret.
+ * <p/>
+ * Each registration is associated to a single Gecko profile, although we don't
+ * enforce that here.  This class is immutable, so it is by definition
+ * thread-safe.
+ */
+public class PushRegistration {
+    public final String autopushEndpoint;
+    public final boolean debug;
+    // TODO: fold (timestamp, {uaid, secret}) into this class.
+    public final @NonNull Fetched uaid;
+    public final String secret;
+
+    protected final @NonNull Map<String, PushSubscription> subscriptions;
+
+    public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret, @NonNull Map<String, PushSubscription> subscriptions) {
+        this.autopushEndpoint = autopushEndpoint;
+        this.debug = debug;
+        this.uaid = uaid;
+        this.secret = secret;
+        this.subscriptions = subscriptions;
+    }
+
+    public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret) {
+        this(autopushEndpoint, debug, uaid, secret, new HashMap<String, PushSubscription>());
+    }
+
+    public JSONObject toJSONObject() throws JSONException {
+        final JSONObject subscriptions = new JSONObject();
+        for (Map.Entry<String, PushSubscription> entry : this.subscriptions.entrySet()) {
+            subscriptions.put(entry.getKey(), entry.getValue().toJSONObject());
+        }
+
+        final JSONObject jsonObject = new JSONObject();
+        jsonObject.put("autopushEndpoint", autopushEndpoint);
+        jsonObject.put("debug", debug);
+        jsonObject.put("uaid", uaid.toJSONObject());
+        jsonObject.put("secret", secret);
+        jsonObject.put("subscriptions", subscriptions);
+        return jsonObject;
+    }
+
+    public static PushRegistration fromJSONObject(@NonNull JSONObject registration) throws JSONException {
+        final String endpoint = registration.optString("autopushEndpoint", null);
+        final boolean debug = registration.getBoolean("debug");
+        final Fetched uaid = Fetched.fromJSONObject(registration.getJSONObject("uaid"));
+        final String secret = registration.optString("secret", null);
+
+        final JSONObject subscriptionsObject = registration.getJSONObject("subscriptions");
+        final Map<String, PushSubscription> subscriptions = new HashMap<>();
+        final Iterator<String> it = subscriptionsObject.keys();
+        while (it.hasNext()) {
+            final String chid = it.next();
+            subscriptions.put(chid, PushSubscription.fromJSONObject(subscriptionsObject.getJSONObject(chid)));
+        }
+
+        return new PushRegistration(endpoint, debug, uaid, secret, subscriptions);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        // Auto-generated.
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        PushRegistration that = (PushRegistration) o;
+
+        if (autopushEndpoint != null ? !autopushEndpoint.equals(that.autopushEndpoint) : that.autopushEndpoint != null)
+            return false;
+        if (!uaid.equals(that.uaid)) return false;
+        if (secret != null ? !secret.equals(that.secret) : that.secret != null) return false;
+        if (subscriptions != null ? !subscriptions.equals(that.subscriptions) : that.subscriptions != null) return false;
+        return (debug == that.debug);
+    }
+
+    @Override
+    public int hashCode() {
+        // Auto-generated.
+        int result = autopushEndpoint != null ? autopushEndpoint.hashCode() : 0;
+        result = 31 * result + (debug ? 1 : 0);
+        result = 31 * result + uaid.hashCode();
+        result = 31 * result + (secret != null ? secret.hashCode() : 0);
+        result = 31 * result + (subscriptions != null ? subscriptions.hashCode() : 0);
+        return result;
+    }
+
+    public PushRegistration withDebug(boolean debug) {
+        return new PushRegistration(this.autopushEndpoint, debug, this.uaid, this.secret, this.subscriptions);
+    }
+
+    public PushRegistration withUserAgentID(String uaid, String secret, long nextNow) {
+        return new PushRegistration(this.autopushEndpoint, this.debug, new Fetched(uaid, nextNow), secret, this.subscriptions);
+    }
+
+    public PushSubscription getSubscription(@NonNull String chid) {
+        return subscriptions.get(chid);
+    }
+
+    public PushSubscription putSubscription(@NonNull String chid, @NonNull PushSubscription subscription) {
+        return subscriptions.put(chid, subscription);
+    }
+
+    public PushSubscription removeSubscription(@NonNull String chid) {
+        return subscriptions.remove(chid);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
@@ -0,0 +1,137 @@
+/* -*- 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.push;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.annotation.WorkerThread;
+import android.support.v4.util.AtomicFile;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Firefox for Android maintains an App-wide mapping associating
+ * profile names to push registrations.  Each push registration in turn associates channels to
+ * push subscriptions.
+ * <p/>
+ * We use a simple storage model of JSON backed by an atomic file.  It is assumed that instances
+ * of this class will reference distinct files on disk; and that all accesses will be happen on a
+ * single (worker thread).
+ */
+public class PushState {
+    private static final String LOG_TAG = "GeckoPushState";
+
+    private static final long VERSION = 1L;
+
+    protected final @NonNull AtomicFile file;
+
+    protected final @NonNull Map<String, PushRegistration> registrations;
+
+    public PushState(Context context, @NonNull String fileName) {
+        this.registrations = new HashMap<>();
+
+        file = new AtomicFile(new File(context.getApplicationInfo().dataDir, fileName));
+        synchronized (file) {
+            try {
+                final String s = new String(file.readFully(), "UTF-8");
+                final JSONObject temp = new JSONObject(s);
+                if (temp.optLong("version", 0L) != VERSION) {
+                    throw new JSONException("Unknown version!");
+                }
+
+                final JSONObject registrationsObject = temp.getJSONObject("registrations");
+                final Iterator<String> it = registrationsObject.keys();
+                while (it.hasNext()) {
+                    final String profileName = it.next();
+                    final PushRegistration registration = PushRegistration.fromJSONObject(registrationsObject.getJSONObject(profileName));
+                    this.registrations.put(profileName, registration);
+                }
+            } catch (FileNotFoundException e) {
+                Log.i(LOG_TAG, "No storage found; starting fresh.");
+                this.registrations.clear();
+            } catch (IOException | JSONException e) {
+                Log.w(LOG_TAG, "Got exception reading storage; dropping storage and starting fresh.", e);
+                this.registrations.clear();
+            }
+        }
+    }
+
+    public JSONObject toJSONObject() throws JSONException {
+        final JSONObject registrations = new JSONObject();
+        for (Map.Entry<String, PushRegistration> entry : this.registrations.entrySet()) {
+            registrations.put(entry.getKey(), entry.getValue().toJSONObject());
+        }
+
+        final JSONObject jsonObject = new JSONObject();
+        jsonObject.put("version", 1L);
+        jsonObject.put("registrations", registrations);
+        return jsonObject;
+    }
+
+    /**
+     * Synchronously persist the cache to disk.
+     * @return whether the cache was persisted successfully.
+     */
+    @WorkerThread
+    public boolean checkpoint() {
+        synchronized (file) {
+            FileOutputStream fileOutputStream = null;
+            try {
+                fileOutputStream = file.startWrite();
+                fileOutputStream.write(toJSONObject().toString().getBytes("UTF-8"));
+                file.finishWrite(fileOutputStream);
+                return true;
+            } catch (JSONException | IOException e) {
+                Log.e(LOG_TAG, "Got exception writing JSON storage; ignoring.", e);
+                if (fileOutputStream != null) {
+                    file.failWrite(fileOutputStream);
+                }
+                return false;
+            }
+        }
+    }
+
+    public PushRegistration putRegistration(@NonNull String profileName, @NonNull PushRegistration registration) {
+        return registrations.put(profileName, registration);
+    }
+
+    /**
+     * Return the existing push registration for the given profile name.
+     * @return the push registration, if one is registered; null otherwise.
+     */
+    public PushRegistration getRegistration(@NonNull String profileName) {
+        return registrations.get(profileName);
+    }
+
+    /**
+     * Return all push registrations, keyed by profile names.
+     * @return a map of all push registrations.  <b>The map is intentionally mutable - be careful!</b>
+     */
+    public @NonNull Map<String, PushRegistration> getRegistrations() {
+        return registrations;
+    }
+
+    /**
+     * Remove any existing push registration for the given profile name.
+     * </p>
+     * Most registration removals are during iteration, which should use an iterator that is
+     * aware of removals.
+     * @return the removed push registration, if one was removed; null otherwise.
+     */
+    public PushRegistration removeRegistration(@NonNull String profileName) {
+        return registrations.remove(profileName);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java
@@ -0,0 +1,81 @@
+/* -*- 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.push;
+
+import android.support.annotation.NonNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Represent an autopush Channel subscription.
+ * <p/>
+ * Such a subscription associates a user agent and autopush data with a channel
+ * ID, a WebPush endpoint, and some service-specific data.
+ * <p/>
+ * Cloud Messaging data, and the returned uaid and secret.
+ * <p/>
+ * Each registration is associated to a single Gecko profile, although we don't
+ * enforce that here.  This class is immutable, so it is by definition
+ * thread-safe.
+ */
+public class PushSubscription {
+    public final @NonNull String chid;
+    public final @NonNull String profileName;
+    public final @NonNull String webpushEndpoint;
+    public final @NonNull String service;
+    public final JSONObject serviceData;
+
+    public PushSubscription(@NonNull String chid, @NonNull String profileName, @NonNull String webpushEndpoint, @NonNull String service, JSONObject serviceData) {
+        this.chid = chid;
+        this.profileName = profileName;
+        this.webpushEndpoint = webpushEndpoint;
+        this.service = service;
+        this.serviceData = serviceData;
+    }
+
+    public JSONObject toJSONObject() throws JSONException {
+        final JSONObject jsonObject = new JSONObject();
+        jsonObject.put("chid", chid);
+        jsonObject.put("profileName", profileName);
+        jsonObject.put("webpushEndpoint", webpushEndpoint);
+        jsonObject.put("service", service);
+        jsonObject.put("serviceData", serviceData);
+        return jsonObject;
+    }
+
+    public static PushSubscription fromJSONObject(@NonNull JSONObject subscription) throws JSONException {
+        final String chid = subscription.getString("chid");
+        final String profileName = subscription.getString("profileName");
+        final String webpushEndpoint = subscription.getString("webpushEndpoint");
+        final String service = subscription.getString("service");
+        final JSONObject serviceData = subscription.optJSONObject("serviceData");
+        return new PushSubscription(chid, profileName, webpushEndpoint, service, serviceData);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        // Auto-generated.
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        PushSubscription that = (PushSubscription) o;
+
+        if (!chid.equals(that.chid)) return false;
+        if (!profileName.equals(that.profileName)) return false;
+        if (!webpushEndpoint.equals(that.webpushEndpoint)) return false;
+        return service.equals(that.service);
+    }
+
+    @Override
+    public int hashCode() {
+        // Auto-generated.
+        int result = profileName.hashCode();
+        result = 31 * result + chid.hashCode();
+        result = 31 * result + webpushEndpoint.hashCode();
+        result = 31 * result + service.hashCode();
+        return result;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.File;
+import java.io.FileOutputStream;
+
+@RunWith(TestRunner.class)
+public class TestPushState {
+    @Test
+    public void testRoundTrip() throws Exception {
+        final PushState state = new PushState(RuntimeEnvironment.application, "test.json");
+        // Fresh state should have no registrations (and no subscriptions).
+        Assert.assertTrue(state.registrations.isEmpty());
+
+        final PushRegistration registration = new PushRegistration("endpoint", true, Fetched.now("uaid"), "secret");
+        final PushSubscription subscription = new PushSubscription("chid", "profileName", "webpushEndpoint", "service", null);
+        registration.putSubscription("chid", subscription);
+        state.putRegistration("profileName", registration);
+        Assert.assertEquals(1, state.registrations.size());
+        state.checkpoint();
+
+        final PushState readState = new PushState(RuntimeEnvironment.application, "test.json");
+        Assert.assertEquals(1, readState.registrations.size());
+        final PushRegistration storedRegistration = readState.getRegistration("profileName");
+        Assert.assertEquals(registration, storedRegistration);
+
+        Assert.assertEquals(1, storedRegistration.subscriptions.size());
+        final PushSubscription storedSubscription = storedRegistration.getSubscription("chid");
+        Assert.assertEquals(subscription, storedSubscription);
+    }
+
+    @Test
+    public void testMissingRegistration() throws Exception {
+        final PushState state = new PushState(RuntimeEnvironment.application, "testMissingRegistration.json");
+        Assert.assertNull(state.getRegistration("missingProfileName"));
+    }
+
+    @Test
+    public void testMissingSubscription() throws Exception {
+        final PushRegistration registration = new PushRegistration("endpoint", true, Fetched.now("uaid"), "secret");
+        Assert.assertNull(registration.getSubscription("missingChid"));
+    }
+
+    @Test
+    public void testCorruptedJSON() throws Exception {
+        // Write some malformed JSON.
+        // TODO: use mcomella's helpers!
+        final File file = new File(RuntimeEnvironment.application.getApplicationInfo().dataDir, "testCorruptedJSON.json");
+        FileOutputStream fos = null;
+        try {
+            fos = new FileOutputStream(file);
+            fos.write("}".getBytes("UTF-8"));
+        } finally {
+            if (fos != null) {
+                fos.close();
+            }
+        }
+
+        final PushState state = new PushState(RuntimeEnvironment.application, "testCorruptedJSON.json");
+        Assert.assertTrue(state.getRegistrations().isEmpty());
+    }
+}