Bug 1207714 - Part 3: Implement push manager. r=rnewman
authorNick Alexander <nalexander@mozilla.com>
Wed, 02 Mar 2016 16:04:02 -0800
changeset 324857 15826b103fd0ac87294bc8c0e913f9916aa0283c
parent 324856 f869f0c13f9062fe26a9bafd63392205581673c4
child 324858 0d112a6d618c9730bddd665f65d75eb60873e9b6
push id1128
push userjlund@mozilla.com
push dateWed, 01 Jun 2016 01:31:59 +0000
treeherdermozilla-release@fe0d30de989d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1207714
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1207714 - Part 3: Implement push manager. r=rnewman MozReview-Commit-ID: LkUaGFA6YlF
mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
@@ -0,0 +1,109 @@
+/* -*- 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.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) throws LocalException, AutopushClientException {
+        final Delegate<SubscribeChannelResponse> delegate = new Delegate<>();
+        autopushClient.subscribeChannel(uaid, secret, 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.
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
@@ -0,0 +1,353 @@
+/* -*- 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.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.EMPTY_MAP;
+        }
+        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, 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, System.currentTimeMillis());
+        return subscription;
+    }
+
+    protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, 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);
+        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();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push;
+
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.UUID;
+
+import static org.mockito.Matchers.anyBoolean;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+
+@RunWith(TestRunner.class)
+public class TestPushManager {
+    private PushState state;
+    private GcmTokenClient gcmTokenClient;
+    private PushClient pushClient;
+    private PushManager manager;
+
+    @Before
+    public void setUp() throws Exception {
+        state = new PushState(RuntimeEnvironment.application, "test.json");
+        gcmTokenClient = mock(GcmTokenClient.class);
+        doReturn(new Fetched("opaque-gcm-token", System.currentTimeMillis())).when(gcmTokenClient).getToken(anyString(), anyBoolean());
+
+        // Configure a mock PushClient.
+        pushClient = mock(PushClient.class);
+        doReturn(new RegisterUserAgentResponse("opaque-uaid", "opaque-secret"))
+                .when(pushClient)
+                .registerUserAgent(anyString());
+
+        doReturn(new SubscribeChannelResponse("opaque-chid", "https://localhost:8085/opaque-push-endpoint"))
+                .when(pushClient)
+                .subscribeChannel(anyString(), anyString());
+
+        PushManager.PushClientFactory pushClientFactory = mock(PushManager.PushClientFactory.class);
+        doReturn(pushClient).when(pushClientFactory).getPushClient(anyString(), anyBoolean());
+
+        manager = new PushManager(state, gcmTokenClient, pushClientFactory);
+    }
+
+    private void assertOnlyConfigured(PushRegistration registration, String endpoint, boolean debug) {
+        Assert.assertNotNull(registration);
+        Assert.assertEquals(registration.autopushEndpoint, endpoint);
+        Assert.assertEquals(registration.debug, debug);
+        Assert.assertNull(registration.uaid.value);
+    }
+
+    private void assertRegistered(PushRegistration registration, String endpoint, boolean debug) {
+        Assert.assertNotNull(registration);
+        Assert.assertEquals(registration.autopushEndpoint, endpoint);
+        Assert.assertEquals(registration.debug, debug);
+        Assert.assertNotNull(registration.uaid.value);
+    }
+
+    private void assertSubscribed(PushSubscription subscription) {
+        Assert.assertNotNull(subscription);
+        Assert.assertNotNull(subscription.chid);
+    }
+
+    @Test
+    public void testConfigure() throws Exception {
+        PushRegistration registration = manager.configure("default", "http://localhost:8081", false, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8081", false);
+
+        registration = manager.configure("default", "http://localhost:8082", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8082", true);
+    }
+
+    @Test(expected=PushManager.ProfileNeedsConfigurationException.class)
+    public void testRegisterBeforeConfigure() throws Exception {
+        PushRegistration registration = state.getRegistration("default");
+        Assert.assertNull(registration);
+
+        // Trying to register a User Agent fails before configuration.
+        manager.registerUserAgent("default", System.currentTimeMillis());
+    }
+
+    @Test
+    public void testRegister() throws Exception {
+        PushRegistration registration = manager.configure("default", "http://localhost:8082", false, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8082", false);
+
+        // Let's register a User Agent, so that we can witness unregistration.
+        registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8082", false);
+
+        // Changing the debug flag should update but not try to unregister the User Agent.
+        registration = manager.configure("default", "http://localhost:8082", true, System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8082", true);
+
+        // Changing the configuration endpoint should update and try to unregister the User Agent.
+        registration = manager.configure("default", "http://localhost:8083", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8083", true);
+    }
+
+    @Test
+    public void testRegisterMultipleProfiles() throws Exception {
+        PushRegistration registration1 = manager.configure("default1", "http://localhost:8081", true, System.currentTimeMillis());
+        PushRegistration registration2 = manager.configure("default2", "http://localhost:8082", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration1, "http://localhost:8081", true);
+        assertOnlyConfigured(registration2, "http://localhost:8082", true);
+        verify(gcmTokenClient, times(0)).getToken(anyString(), anyBoolean());
+
+        registration1 = manager.registerUserAgent("default1", System.currentTimeMillis());
+        assertRegistered(registration1, "http://localhost:8081", true);
+
+        registration2 = manager.registerUserAgent("default2", System.currentTimeMillis());
+        assertRegistered(registration2, "http://localhost:8082", true);
+
+        // Just the debug flag should not unregister the User Agent.
+        registration1 = manager.configure("default1", "http://localhost:8081", false, System.currentTimeMillis());
+        assertRegistered(registration1, "http://localhost:8081", false);
+
+        // But the configuration endpoint should unregister the correct User Agent.
+        registration2 = manager.configure("default2", "http://localhost:8083", false, System.currentTimeMillis());
+    }
+
+    @Test
+    public void testSubscribeChannel() throws Exception {
+        manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+        PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8080", false);
+
+        // We should be able to register with non-null serviceData.
+        final JSONObject webpushData = new JSONObject();
+        webpushData.put("version", 5);
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, System.currentTimeMillis());
+        assertSubscribed(subscription);
+
+        subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
+        Assert.assertNotNull(subscription);
+        Assert.assertEquals(5, subscription.serviceData.get("version"));
+
+        // We should be able to register with null serviceData.
+        subscription = manager.subscribeChannel("default", "sync", null, System.currentTimeMillis());
+        assertSubscribed(subscription);
+
+        subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
+        Assert.assertNotNull(subscription);
+        Assert.assertNull(subscription.serviceData);
+    }
+
+    @Test
+    public void testUnsubscribeChannel() throws Exception {
+        manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+        PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8080", false);
+
+        // We should be able to register with non-null serviceData.
+        final JSONObject webpushData = new JSONObject();
+        webpushData.put("version", 5);
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, System.currentTimeMillis());
+        assertSubscribed(subscription);
+
+        // No exception is success.
+        manager.unsubscribeChannel(subscription.chid);
+    }
+
+    public void testUnsubscribeUnknownChannel() throws Exception {
+        manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+        PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8080", false);
+
+        doThrow(new RuntimeException())
+                .when(pushClient)
+                .unsubscribeChannel(anyString(), anyString(), anyString());
+
+        // Un-subscribing from an unknown channel succeeds: we just ignore the request.
+        manager.unsubscribeChannel(UUID.randomUUID().toString());
+    }
+
+    @Test
+    public void testStartupBeforeConfiguration() throws Exception {
+        verify(gcmTokenClient, never()).getToken(anyString(), anyBoolean());
+        manager.startup(System.currentTimeMillis());
+        verify(gcmTokenClient, times(1)).getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false);
+    }
+
+    @Test
+    public void testStartupBeforeRegistration() throws Exception {
+        PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+        manager.startup(System.currentTimeMillis());
+        verify(gcmTokenClient, times(1)).getToken(anyString(), anyBoolean());
+    }
+
+    @Test
+    public void testStartupAfterRegistration() throws Exception {
+        PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+        registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8080", true);
+
+        manager.startup(System.currentTimeMillis());
+
+        // Rather tautological.
+        PushRegistration updatedRegistration = manager.state.getRegistration("default");
+        Assert.assertEquals(registration.uaid, updatedRegistration.uaid);
+    }
+
+    @Test
+    public void testStartupAfterSubscription() throws Exception {
+        PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+        assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+        registration = manager.registerUserAgent("default", System.currentTimeMillis());
+        assertRegistered(registration, "http://localhost:8080", true);
+
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", null, System.currentTimeMillis());
+        assertSubscribed(subscription);
+
+        manager.startup(System.currentTimeMillis());
+
+        // Rather tautological.
+        registration = manager.registrationForSubscription(subscription.chid);
+        PushSubscription updatedSubscription = registration.getSubscription(subscription.chid);
+        Assert.assertEquals(subscription.chid, updatedSubscription.chid);
+    }
+}