summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/push
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/push
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/push')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/Fetched.java71
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushClient.java110
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushManager.java354
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java126
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushService.java460
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushState.java137
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java81
7 files changed, 1339 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java
new file mode 100644
index 000000000..42a7c6a90
--- /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;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
new file mode 100644
index 000000000..9c1fab5f9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
@@ -0,0 +1,110 @@
+/* -*- 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.mozilla.gecko.push.RegisterUserAgentResponse;
+import org.mozilla.gecko.push.SubscribeChannelResponse;
+import org.mozilla.gecko.push.autopush.AutopushClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.sync.Utils;
+
+import java.util.concurrent.Executor;
+
+/**
+ * This class bridges the autopush client, which is written in callback style, with the Fennec
+ * push implementation, which is written in a linear style. It handles returning results and
+ * re-throwing exceptions passed as messages.
+ * <p/>
+ * TODO: fold this into the autopush client directly.
+ */
+public class PushClient {
+ public static class LocalException extends Exception {
+ private static final long serialVersionUID = 2387554736L;
+
+ public LocalException(Throwable throwable) {
+ super(throwable);
+ }
+ }
+
+ private final AutopushClient autopushClient;
+
+ public PushClient(String serverURI) {
+ this.autopushClient = new AutopushClient(serverURI, Utils.newSynchronousExecutor());
+ }
+
+ /**
+ * Each instance is <b>single-use</b>! Exactly one delegate method should be invoked once,
+ * but we take care to handle multiple invocations (favoring the earliest), just to be safe.
+ */
+ protected static class Delegate<T> implements AutopushClient.RequestDelegate<T> {
+ Object result; // Oh, for an algebraic data type when you need one!
+
+ @SuppressWarnings("unchecked")
+ public T responseOrThrow() throws LocalException, AutopushClientException {
+ if (result instanceof LocalException) {
+ throw (LocalException) result;
+ }
+ if (result instanceof AutopushClientException) {
+ throw (AutopushClientException) result;
+ }
+ return (T) result;
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ if (result == null) {
+ result = new LocalException(e);
+ }
+ }
+
+ @Override
+ public void handleFailure(AutopushClientException e) {
+ if (result == null) {
+ result = e;
+ }
+ }
+
+ @Override
+ public void handleSuccess(T response) {
+ if (result == null) {
+ result = response;
+ }
+ }
+ }
+
+ public RegisterUserAgentResponse registerUserAgent(@NonNull String token) throws LocalException, AutopushClientException {
+ final Delegate<RegisterUserAgentResponse> delegate = new Delegate<>();
+ autopushClient.registerUserAgent(token, delegate);
+ return delegate.responseOrThrow();
+ }
+
+ public void reregisterUserAgent(@NonNull String uaid, @NonNull String secret, @NonNull String token) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.reregisterUserAgent(uaid, secret, token, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+
+ public void unregisterUserAgent(@NonNull String uaid, @NonNull String secret) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.unregisterUserAgent(uaid, secret, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+
+ public SubscribeChannelResponse subscribeChannel(@NonNull String uaid, @NonNull String secret, @Nullable String appServerKey) throws LocalException, AutopushClientException {
+ final Delegate<SubscribeChannelResponse> delegate = new Delegate<>();
+ autopushClient.subscribeChannel(uaid, secret, appServerKey, delegate);
+ return delegate.responseOrThrow();
+ }
+
+ public void unsubscribeChannel(@NonNull String uaid, @NonNull String secret, @NonNull String chid) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.unsubscribeChannel(uaid, secret, chid, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
new file mode 100644
index 000000000..42ef60b61
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
@@ -0,0 +1,354 @@
+/* -*- 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 android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * The push manager advances push registrations, ensuring that the upstream autopush endpoint has
+ * a fresh GCM token. It brokers channel subscription requests to the upstream and maintains
+ * local state.
+ * <p/>
+ * This class is not thread safe. An individual instance should be accessed on a single
+ * (background) thread.
+ */
+public class PushManager {
+ public static final long TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS = 7 * 24 * 60 * 60 * 1000L; // One week.
+
+ public static class ProfileNeedsConfigurationException extends Exception {
+ private static final long serialVersionUID = 3326738888L;
+
+ public ProfileNeedsConfigurationException() {
+ super();
+ }
+ }
+
+ private static final String LOG_TAG = "GeckoPushManager";
+
+ protected final @NonNull PushState state;
+ protected final @NonNull GcmTokenClient gcmClient;
+ protected final @NonNull PushClientFactory pushClientFactory;
+
+ // For testing only.
+ public interface PushClientFactory {
+ PushClient getPushClient(String autopushEndpoint, boolean debug);
+ }
+
+ public PushManager(@NonNull PushState state, @NonNull GcmTokenClient gcmClient, @NonNull PushClientFactory pushClientFactory) {
+ this.state = state;
+ this.gcmClient = gcmClient;
+ this.pushClientFactory = pushClientFactory;
+ }
+
+ public PushRegistration registrationForSubscription(String chid) {
+ // chids are globally unique, so we're not concerned about finding a chid associated to
+ // any particular profile.
+ for (Map.Entry<String, PushRegistration> entry : state.getRegistrations().entrySet()) {
+ final PushSubscription subscription = entry.getValue().getSubscription(chid);
+ if (subscription != null) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ public Map<String, PushSubscription> allSubscriptionsForProfile(String profileName) {
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null) {
+ return Collections.emptyMap();
+ }
+ return Collections.unmodifiableMap(registration.subscriptions);
+ }
+
+ public PushRegistration registerUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ Log.i(LOG_TAG, "Registering user agent for profile named: " + profileName);
+ return advanceRegistration(profileName, now);
+ }
+
+ public PushRegistration unregisterUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException {
+ Log.i(LOG_TAG, "Unregistering user agent for profile named: " + profileName);
+
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote uaid for profileName: " + profileName);
+ return null;
+ }
+
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ Log.e(LOG_TAG, "Cannot unregisterUserAgent with null registration uaid or secret!");
+ return null;
+ }
+
+ unregisterUserAgentOnBackgroundThread(registration);
+ return registration;
+ }
+
+ public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ Log.i(LOG_TAG, "Subscribing to channel for service: " + service + "; for profile named: " + profileName);
+ final PushRegistration registration = advanceRegistration(profileName, now);
+ final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, appServerKey, System.currentTimeMillis());
+ return subscription;
+ }
+
+ protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws AutopushClientException, PushClient.LocalException {
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ throw new IllegalStateException("Cannot subscribeChannel with null uaid or secret!");
+ }
+
+ // Verify endpoint is not null?
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+
+ final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret, appServerKey);
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Got chid: " + result.channelID + " and endpoint: " + result.endpoint);
+ } else {
+ Log.i(LOG_TAG, "Got chid and endpoint.");
+ }
+
+ final PushSubscription subscription = new PushSubscription(result.channelID, profileName, result.endpoint, service, serviceData);
+ registration.putSubscription(result.channelID, subscription);
+ state.checkpoint();
+
+ return subscription;
+ }
+
+ public PushSubscription unsubscribeChannel(final @NonNull String chid) {
+ Log.i(LOG_TAG, "Unsubscribing from channel with chid: " + chid);
+
+ final PushRegistration registration = registrationForSubscription(chid);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote subscription: " + chid);
+ return null;
+ }
+
+ // We remove the local subscription before the remote subscription: without the local
+ // subscription we'll ignoring incoming messages, and after some amount of time the
+ // server will expire the channel due to non-activity. This is also Desktop's approach.
+ final PushSubscription subscription = registration.removeSubscription(chid);
+ state.checkpoint();
+
+ if (subscription == null) {
+ // This should never happen.
+ Log.e(LOG_TAG, "Subscription did not exist: " + chid);
+ return null;
+ }
+
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ Log.e(LOG_TAG, "Cannot unsubscribeChannel with null registration uaid or secret!");
+ return null;
+ }
+
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+ // Fire and forget.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ pushClient.unsubscribeChannel(registration.uaid.value, registration.secret, chid);
+ Log.i(LOG_TAG, "Unsubscribed from channel with chid: " + chid);
+ } catch (PushClient.LocalException | AutopushClientException e) {
+ Log.w(LOG_TAG, "Failed to unsubscribe from channel with chid; ignoring: " + chid, e);
+ }
+ }
+ });
+
+ return subscription;
+ }
+
+ public PushRegistration configure(final @NonNull String profileName, final @NonNull String endpoint, final boolean debug, final long now) {
+ Log.i(LOG_TAG, "Updating configuration.");
+ final PushRegistration registration = state.getRegistration(profileName);
+ final PushRegistration newRegistration;
+ if (registration != null) {
+ if (!endpoint.equals(registration.autopushEndpoint)) {
+ if (debug) {
+ Log.i(LOG_TAG, "Push configuration autopushEndpoint changed! Was: " + registration.autopushEndpoint + "; now: " + endpoint);
+ } else {
+ Log.i(LOG_TAG, "Push configuration autopushEndpoint changed!");
+ }
+
+ newRegistration = new PushRegistration(endpoint, debug, Fetched.now(null), null);
+
+ if (registration.uaid.value != null) {
+ // New endpoint! All registrations and subscriptions have been dropped, and
+ // should be removed remotely.
+ unregisterUserAgentOnBackgroundThread(registration);
+ }
+ } else if (debug != registration.debug) {
+ Log.i(LOG_TAG, "Push configuration debug changed: " + debug);
+ newRegistration = registration.withDebug(debug);
+ } else {
+ newRegistration = registration;
+ }
+ } else {
+ if (debug) {
+ Log.i(LOG_TAG, "Push configuration set: " + endpoint + "; debug: " + debug);
+ } else {
+ Log.i(LOG_TAG, "Push configuration set!");
+ }
+ newRegistration = new PushRegistration(endpoint, debug, new Fetched(null, now), null);
+ }
+
+ if (newRegistration != registration) {
+ state.putRegistration(profileName, newRegistration);
+ state.checkpoint();
+ }
+
+ return newRegistration;
+ }
+
+ private void unregisterUserAgentOnBackgroundThread(final PushRegistration registration) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug).unregisterUserAgent(registration.uaid.value, registration.secret);
+ Log.i(LOG_TAG, "Unregistered user agent with uaid: " + registration.uaid.value);
+ } catch (PushClient.LocalException | AutopushClientException e) {
+ Log.w(LOG_TAG, "Failed to unregister user agent with uaid; ignoring: " + registration.uaid.value, e);
+ }
+ }
+ });
+ }
+
+ protected @NonNull PushRegistration advanceRegistration(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null || registration.autopushEndpoint == null) {
+ Log.i(LOG_TAG, "Cannot advance to registered: registration needs configuration.");
+ throw new ProfileNeedsConfigurationException();
+ }
+ return advanceRegistration(registration, profileName, now);
+ }
+
+ protected @NonNull PushRegistration advanceRegistration(final PushRegistration registration, final @NonNull String profileName, final long now) throws AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ final Fetched gcmToken = gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, registration.debug);
+
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+
+ if (registration.uaid.value == null) {
+ if (registration.debug) {
+ Log.i(LOG_TAG, "No uaid; requesting from autopush endpoint: " + registration.autopushEndpoint);
+ } else {
+ Log.i(LOG_TAG, "No uaid: requesting from autopush endpoint.");
+ }
+ final RegisterUserAgentResponse result = pushClient.registerUserAgent(gcmToken.value);
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Got uaid: " + result.uaid + " and secret: " + result.secret);
+ } else {
+ Log.i(LOG_TAG, "Got uaid and secret.");
+ }
+ final long nextNow = System.currentTimeMillis();
+ final PushRegistration nextRegistration = registration.withUserAgentID(result.uaid, result.secret, nextNow);
+ state.putRegistration(profileName, nextRegistration);
+ state.checkpoint();
+ return advanceRegistration(nextRegistration, profileName, nextNow);
+ }
+
+ if (registration.uaid.timestamp + TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS < now
+ || registration.uaid.timestamp < gcmToken.timestamp) {
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Stale uaid; re-registering with autopush endpoint: " + registration.autopushEndpoint);
+ } else {
+ Log.i(LOG_TAG, "Stale uaid: re-registering with autopush endpoint.");
+ }
+
+ pushClient.reregisterUserAgent(registration.uaid.value, registration.secret, gcmToken.value);
+
+ Log.i(LOG_TAG, "Re-registered uaid and secret.");
+ final long nextNow = System.currentTimeMillis();
+ final PushRegistration nextRegistration = registration.withUserAgentID(registration.uaid.value, registration.secret, nextNow);
+ state.putRegistration(profileName, nextRegistration);
+ state.checkpoint();
+ return advanceRegistration(nextRegistration, profileName, nextNow);
+ }
+
+ Log.d(LOG_TAG, "Existing uaid is fresh; no need to request from autopush endpoint.");
+ return registration;
+ }
+
+ public void invalidateGcmToken() {
+ gcmClient.invalidateToken();
+ }
+
+ public void startup(long now) {
+ try {
+ Log.i(LOG_TAG, "Startup: requesting GCM token.");
+ gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false); // For side-effects.
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ // Requires user intervention. At App startup, we don't want to address this. In
+ // response to user activity, we do want to try to have the user address this.
+ Log.w(LOG_TAG, "Startup: needs Google Play Services. Ignoring until GCM is requested in response to user activity.");
+ return;
+ } catch (IOException e) {
+ // We're temporarily unable to get a GCM token. There's nothing to be done; we'll
+ // try to advance the App's state in response to user activity or at next startup.
+ Log.w(LOG_TAG, "Startup: Google Play Services is available, but we can't get a token; ignoring.", e);
+ return;
+ }
+
+ Log.i(LOG_TAG, "Startup: advancing all registrations.");
+ final Map<String, PushRegistration> registrations = state.getRegistrations();
+
+ // Now advance all registrations.
+ try {
+ final Iterator<Map.Entry<String, PushRegistration>> it = registrations.entrySet().iterator();
+ while (it.hasNext()) {
+ final Map.Entry<String, PushRegistration> entry = it.next();
+ final String profileName = entry.getKey();
+ final PushRegistration registration = entry.getValue();
+ if (registration.subscriptions.isEmpty()) {
+ Log.i(LOG_TAG, "Startup: no subscriptions for profileName; not advancing registration: " + profileName);
+ continue;
+ }
+
+ try {
+ advanceRegistration(profileName, now); // For side-effects.
+ Log.i(LOG_TAG, "Startup: advanced registration for profileName: " + profileName);
+ } catch (ProfileNeedsConfigurationException e) {
+ Log.i(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; profile needs configuration from Gecko.");
+ } catch (AutopushClientException e) {
+ if (e.isTransientError()) {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got transient autopush error. Ignoring; will advance on demand.", e);
+ } else {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got permanent autopush error. Removing registration entirely.", e);
+ it.remove();
+ }
+ } catch (PushClient.LocalException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got local exception. Ignoring; will advance on demand.", e);
+ }
+ }
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance any registrations; need Google Play Services!", e);
+ return;
+ } catch (IOException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance any registrations; intermittent Google Play Services exception; ignoring, will advance on demand.", e);
+ return;
+ }
+
+ // We may have removed registrations above. Checkpoint just to be safe!
+ state.checkpoint();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java
new file mode 100644
index 000000000..a991774ff
--- /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);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
new file mode 100644
index 000000000..8d3a92e48
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -0,0 +1,460 @@
+/* -*- 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.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoService;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.fxa.FxAccountPushHandler;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
+ * <p/>
+ * This singleton services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud
+ * Messaging requests.
+ * <p/>
+ * It is expected that Gecko is started (if not already running) soon after receiving GCM messages
+ * otherwise there is a greater risk that pending messages that have not been handle by Gecko will
+ * be lost if this service is killed.
+ * <p/>
+ * It's worth noting that we allow the DOM push API in restricted profiles.
+ */
+@ReflectionTarget
+public class PushService implements BundleEventListener {
+ private static final String LOG_TAG = "GeckoPushService";
+
+ public static final String SERVICE_WEBPUSH = "webpush";
+ public static final String SERVICE_FXA = "fxa";
+
+ private static PushService sInstance;
+
+ private static final String[] GECKO_EVENTS = new String[] {
+ "PushServiceAndroidGCM:Configure",
+ "PushServiceAndroidGCM:DumpRegistration",
+ "PushServiceAndroidGCM:DumpSubscriptions",
+ "PushServiceAndroidGCM:Initialized",
+ "PushServiceAndroidGCM:Uninitialized",
+ "PushServiceAndroidGCM:RegisterUserAgent",
+ "PushServiceAndroidGCM:UnregisterUserAgent",
+ "PushServiceAndroidGCM:SubscribeChannel",
+ "PushServiceAndroidGCM:UnsubscribeChannel",
+ "FxAccountsPush:Initialized",
+ "FxAccountsPush:ReceivedPushMessageToDecode:Response",
+ "History:GetPrePathLastVisitedTimeMilliseconds",
+ };
+
+ private enum GeckoComponent {
+ FxAccountsPush,
+ PushServiceAndroidGCM
+ }
+
+ public static synchronized PushService getInstance(Context context) {
+ if (sInstance == null) {
+ onCreate(context);
+ }
+ return sInstance;
+ }
+
+ @ReflectionTarget
+ public static synchronized void onCreate(Context context) {
+ if (sInstance != null) {
+ return;
+ }
+ sInstance = new PushService(context);
+
+ sInstance.registerGeckoEventListener();
+ sInstance.onStartup();
+ }
+
+ protected final PushManager pushManager;
+
+ // NB: These are not thread-safe, we're depending on these being access from the same background thread.
+ private boolean isReadyPushServiceAndroidGCM = false;
+ private boolean isReadyFxAccountsPush = false;
+ private final List<JSONObject> pendingPushMessages;
+
+ public PushService(Context context) {
+ pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
+ @Override
+ public PushClient getPushClient(String autopushEndpoint, boolean debug) {
+ return new PushClient(autopushEndpoint);
+ }
+ });
+
+ pendingPushMessages = new LinkedList<>();
+ }
+
+ public void onStartup() {
+ Log.i(LOG_TAG, "Starting up.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onRefresh() {
+ Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ pushManager.invalidateGcmToken();
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onMessageReceived(final @NonNull Context context, final @NonNull Bundle bundle) {
+ Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ final String chid = bundle.getString("chid");
+ if (chid == null) {
+ Log.w(LOG_TAG, "No chid found; ignoring message.");
+ return;
+ }
+
+ final PushRegistration registration = pushManager.registrationForSubscription(chid);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ final PushSubscription subscription = registration.getSubscription(chid);
+ if (subscription == null) {
+ // This should never happen. There's not much to be done; in the future, perhaps we
+ // could try to drop the remote subscription?
+ Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ boolean isWebPush = SERVICE_WEBPUSH.equals(subscription.service);
+ boolean isFxAPush = SERVICE_FXA.equals(subscription.service);
+ if (!isWebPush && !isFxAPush) {
+ Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service);
+ return;
+ }
+
+ Log.i(LOG_TAG, "Message directed to service: " + subscription.service);
+
+ if (subscription.serviceData == null) {
+ Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.SERVICE, "dom-push-api");
+
+ final String profileName = subscription.serviceData.optString("profileName", null);
+ final String profilePath = subscription.serviceData.optString("profilePath", null);
+ if (profileName == null || profilePath == null) {
+ Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ if (canSendPushMessagesToGecko()) {
+ if (!GeckoThread.canUseProfile(profileName, new File(profilePath))) {
+ Log.e(LOG_TAG, "Mismatched profile for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+ } else {
+ final Intent intent = GeckoService.getIntentToCreateServices(context, "android-push-service");
+ GeckoService.setIntentProfile(intent, profileName, profilePath);
+ context.startService(intent);
+ }
+
+ final JSONObject data = new JSONObject();
+ try {
+ data.put("channelID", chid);
+ data.put("con", bundle.getString("con"));
+ data.put("enc", bundle.getString("enc"));
+ // Only one of cryptokey (newer) and enckey (deprecated) should be set, but the
+ // Gecko handler will verify this.
+ data.put("cryptokey", bundle.getString("cryptokey"));
+ data.put("enckey", bundle.getString("enckey"));
+ data.put("message", bundle.getString("body"));
+
+ if (!canSendPushMessagesToGecko()) {
+ data.put("profileName", profileName);
+ data.put("profilePath", profilePath);
+ data.put("service", subscription.service);
+ }
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e);
+ return;
+ }
+
+ if (!canSendPushMessagesToGecko()) {
+ Log.i(LOG_TAG, "Required service not initialized, adding message to queue.");
+ pendingPushMessages.add(data);
+ return;
+ }
+
+ if (isWebPush) {
+ sendMessageToGeckoService(data);
+ } else {
+ sendMessageToDecodeToGeckoService(data);
+ }
+ }
+
+ protected static void sendMessageToGeckoService(final @NonNull JSONObject message) {
+ Log.i(LOG_TAG, "Delivering dom/push message to Gecko!");
+ GeckoAppShell.notifyObservers("PushServiceAndroidGCM:ReceivedPushMessage",
+ message.toString(),
+ GeckoThread.State.PROFILE_READY);
+ }
+
+ protected static void sendMessageToDecodeToGeckoService(final @NonNull JSONObject message) {
+ Log.i(LOG_TAG, "Delivering dom/push message to decode to Gecko!");
+ GeckoAppShell.notifyObservers("FxAccountsPush:ReceivedPushMessageToDecode",
+ message.toString(),
+ GeckoThread.State.PROFILE_READY);
+ }
+
+ protected void registerGeckoEventListener() {
+ Log.d(LOG_TAG, "Registered Gecko event listener.");
+ EventDispatcher.getInstance().registerBackgroundThreadListener(this, GECKO_EVENTS);
+ }
+
+ protected void unregisterGeckoEventListener() {
+ Log.d(LOG_TAG, "Unregistered Gecko event listener.");
+ EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS);
+ }
+
+ @Override
+ public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
+ Log.i(LOG_TAG, "Handling event: " + event);
+ ThreadUtils.assertOnBackgroundThread();
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ // We're invoked in response to a Gecko message on a background thread. We should always
+ // be able to safely retrieve the current Gecko profile.
+ final GeckoProfile geckoProfile = GeckoProfile.get(context);
+
+ if (callback == null) {
+ Log.e(LOG_TAG, "callback must not be null in " + event);
+ return;
+ }
+
+ try {
+ if ("PushServiceAndroidGCM:Initialized".equals(event)) {
+ processComponentState(GeckoComponent.PushServiceAndroidGCM, true);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:Uninitialized".equals(event)) {
+ processComponentState(GeckoComponent.PushServiceAndroidGCM, false);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("FxAccountsPush:Initialized".equals(event)) {
+ processComponentState(GeckoComponent.FxAccountsPush, true);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:Configure".equals(event)) {
+ final String endpoint = message.getString("endpoint");
+ if (endpoint == null) {
+ callback.sendError("endpoint must not be null in " + event);
+ return;
+ }
+ final boolean debug = message.getBoolean("debug", false);
+ pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects.
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
+ // In the future, this might be used to interrogate the Java Push Manager
+ // registration state from JavaScript.
+ callback.sendError("Not yet implemented!");
+ return;
+ }
+ if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) {
+ try {
+ final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName());
+
+ final JSONObject json = new JSONObject();
+ for (Map.Entry<String, PushSubscription> entry : result.entrySet()) {
+ json.put(entry.getKey(), entry.getValue().toJSONObject());
+ }
+ callback.sendSuccess(json);
+ } catch (JSONException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ }
+ return;
+ }
+ if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
+ try {
+ pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects.
+ callback.sendSuccess(null);
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ }
+ return;
+ }
+ if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
+ // In the future, this might be used to tell the Java Push Manager to unregister
+ // a User Agent entirely from JavaScript. Right now, however, everything is
+ // subscription based; there's no concept of unregistering all subscriptions
+ // simultaneously.
+ callback.sendError("Not yet implemented!");
+ return;
+ }
+ if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
+ final String service = SERVICE_FXA.equals(message.getString("service")) ?
+ SERVICE_FXA :
+ SERVICE_WEBPUSH;
+ final JSONObject serviceData;
+ final String appServerKey = message.getString("appServerKey");
+ try {
+ serviceData = new JSONObject();
+ serviceData.put("profileName", geckoProfile.getName());
+ serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final PushSubscription subscription;
+ try {
+ subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis());
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("channelID", subscription.chid);
+ json.put("endpoint", subscription.webpushEndpoint);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
+ callback.sendSuccess(json);
+ return;
+ }
+ if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
+ final String channelID = message.getString("channelID");
+ if (channelID == null) {
+ callback.sendError("channelID must not be null in " + event);
+ return;
+ }
+
+ // Fire and forget. See comments in the function itself.
+ final PushSubscription pushSubscription = pushManager.unsubscribeChannel(channelID);
+ if (pushSubscription != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.sendError("Could not unsubscribe from channel: " + channelID);
+ return;
+ }
+ if ("FxAccountsPush:ReceivedPushMessageToDecode:Response".equals(event)) {
+ FxAccountPushHandler.handleFxAPushMessage(context, message);
+ return;
+ }
+ if ("History:GetPrePathLastVisitedTimeMilliseconds".equals(event)) {
+ if (callback == null) {
+ Log.e(LOG_TAG, "callback must not be null in " + event);
+ return;
+ }
+ final String prePath = message.getString("prePath");
+ if (prePath == null) {
+ callback.sendError("prePath must not be null in " + event);
+ return;
+ }
+ // We're on a background thread, so we can be synchronous.
+ final long millis = BrowserDB.from(geckoProfile).getPrePathLastVisitedTimeMilliseconds(
+ context.getContentResolver(), prePath);
+ callback.sendSuccess(millis);
+ return;
+ }
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ // TODO: improve this. Can we find a point where the user is *definitely* interacting
+ // with the WebPush? Perhaps we can show a dialog when interacting with the Push
+ // permissions, and then be more aggressive showing this notification when we have
+ // registrations and subscriptions that can't be advanced.
+ callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
+ }
+ }
+
+ private void processComponentState(@NonNull GeckoComponent component, boolean isReady) {
+ if (component == GeckoComponent.FxAccountsPush) {
+ isReadyFxAccountsPush = isReady;
+
+ } else if (component == GeckoComponent.PushServiceAndroidGCM) {
+ isReadyPushServiceAndroidGCM = isReady;
+ }
+
+ // Send all pending messages to Gecko.
+ if (canSendPushMessagesToGecko()) {
+ sendPushMessagesToGecko(pendingPushMessages);
+ pendingPushMessages.clear();
+ }
+ }
+
+ private boolean canSendPushMessagesToGecko() {
+ return isReadyFxAccountsPush && isReadyPushServiceAndroidGCM;
+ }
+
+ private static void sendPushMessagesToGecko(@NonNull List<JSONObject> messages) {
+ for (JSONObject pushMessage : messages) {
+ final String profileName = pushMessage.optString("profileName", null);
+ final String profilePath = pushMessage.optString("profilePath", null);
+ final String service = pushMessage.optString("service", null);
+ if (profileName == null || profilePath == null ||
+ !GeckoThread.canUseProfile(profileName, new File(profilePath))) {
+ Log.e(LOG_TAG, "Mismatched profile for chid: " +
+ pushMessage.optString("channelID") +
+ "; ignoring dom/push message.");
+ continue;
+ }
+ if (SERVICE_WEBPUSH.equals(service)) {
+ sendMessageToGeckoService(pushMessage);
+ } else if (SERVICE_FXA.equals(service)) {
+ sendMessageToDecodeToGeckoService(pushMessage);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushState.java b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
new file mode 100644
index 000000000..686bf5a0d
--- /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);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java
new file mode 100644
index 000000000..ecf752591
--- /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;
+ }
+}