Bug 1207714 - Part 2: Add storage for App-wide push state. r?rnewman
MozReview-Commit-ID: GOP4F6N2Mht
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());
+ }
+}