diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/push | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-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')
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; + } +} |