Bug 1207714 - Part 3: Implement push manager. r?rnewman draft
authorNick Alexander <nalexander@mozilla.com>
Thu, 25 Feb 2016 10:03:53 -0800
changeset 336296 0ca118abbe831b931531706c468c84b0d3d5ad5d
parent 336295 bd71061e26bad26960dfaa5e3eaefb0cfd33939d
child 336297 5e075b996af6f37cc96059352e4162539d8b00a3
push id12029
push usernalexander@mozilla.com
push dateThu, 03 Mar 2016 00:10:36 +0000
reviewersrnewman
bugs1207714
milestone47.0a1
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/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.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,99 @@
+/* -*- 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());
+    }
+
+    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) {
+            result = new LocalException(e);
+        }
+
+        @Override
+        public void handleFailure(AutopushClientException e) {
+            result = e;
+        }
+
+        @Override
+        public void handleSuccess(T response) {
+            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,350 @@
+/* -*- 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.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;
+
+/**
+ * XXX fill comment.
+ */
+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 new HashMap<>();
+        }
+        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) {
+            // Logger.pii(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;
+        }
+
+        final PushSubscription subscription = registration.removeSubscription(chid);
+        state.checkpoint();
+
+        if (subscription == null) {
+            // This should never happen.
+            registration.removeSubscription(chid);
+            state.checkpoint();
+
+            Log.e(LOG_TAG, "Subscription does 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!");
+                } else {
+                    Log.i(LOG_TAG, "Push configuration autopushEndpoint changed! Was: " + registration.autopushEndpoint + "; now: " + endpoint);
+                }
+
+                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!");
+            } else {
+                Log.i(LOG_TAG, "Push configuration set: " + endpoint + "; debug: " + debug);
+            }
+            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);
+                }
+            }
+        });
+    }
+
+    public static final String MOZ_ANDROID_GCM_SENDER_ID = "829133274407";
+
+    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(MOZ_ANDROID_GCM_SENDER_ID);
+        Log.w(LOG_TAG, "Using GCM token: " + gcmToken.value); // XXX PII
+
+        final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+
+        if (registration.uaid.value == null) {
+            if (registration.debug) {
+                // Logger.pii(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) {
+                // Logger.pii(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);
+        } else if (registration.uaid.timestamp + TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS < now
+                || registration.uaid.timestamp < gcmToken.timestamp) {
+            if (registration.debug) {
+                // Logger.pii(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);
+        } else {
+            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(MOZ_ANDROID_GCM_SENDER_ID); // 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();
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
@@ -4,17 +4,17 @@
 
 package org.mozilla.gecko.util;
 
 import android.os.Handler;
 import android.os.Looper;
 
 import java.util.concurrent.SynchronousQueue;
 
-final class GeckoBackgroundThread extends Thread {
+public final class GeckoBackgroundThread extends Thread {
     private static final String LOOPER_NAME = "GeckoBackgroundThread";
 
     // Guarded by 'GeckoBackgroundThread.class'.
     private static Handler handler;
     private static Thread thread;
 
     // The initial Runnable to run on the new thread. Its purpose
     // is to avoid us having to wait for the new thread to start.
@@ -47,17 +47,18 @@ final class GeckoBackgroundThread extend
         thread = new GeckoBackgroundThread(initialRunnable);
         ThreadUtils.setBackgroundThread(thread);
 
         thread.setDaemon(true);
         thread.start();
     }
 
     // Get a Handler for a looper thread, or create one if it doesn't yet exist.
-    /*package*/ static synchronized Handler getHandler() {
+    // Public for testing only.
+    public static synchronized Handler getHandler() {
         if (thread == null) {
             startThread(null);
         }
 
         while (handler == null) {
             try {
                 GeckoBackgroundThread.class.wait();
             } catch (final InterruptedException e) {
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
@@ -0,0 +1,271 @@
+/* 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.background.testhelpers.TestRunner;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.util.GeckoBackgroundThread;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.internal.ShadowExtractor;
+import org.robolectric.shadows.ShadowLooper;
+
+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;
+
+
+// TODO: XXX: notes about timestamps and clocks.
+// TODO: XXX: notes about threading and concurrency.
+@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("opaque-gcm-token").when(gcmTokenClient).getToken(anyString());
+
+        // 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);
+
+//        // But the configuration endpoint should unregister the correct User Agent.
+//        registration = manager.configure("default", "http://localhost:8083", false, System.currentTimeMillis());
+//        assertOnlyConfigured(registration, "http://localhost:8083", false);
+//        // We shouldn't touch the other profile's registration.
+//        assertRegistered(registration1, "http://localhost:8081", false);
+    }
+
+    @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());
+
+        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);
+
+        // The actual unsubscribe request is made on the Gecko background thread; wait for it to
+        // clear before verifying the push client was invoked.
+        // Cribbed from http://stackoverflow.com/a/33928853.
+        ((ShadowLooper) ShadowExtractor.extract(GeckoBackgroundThread.getHandler().getLooper())).idle();
+        verify(pushClient).unsubscribeChannel(registration.uaid.value, registration.secret, 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());
+        manager.startup(System.currentTimeMillis());
+        verify(gcmTokenClient, times(1)).getToken(PushManager.MOZ_ANDROID_GCM_SENDER_ID);
+    }
+
+    @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());
+    }
+
+    @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);
+
+        // Now, try well into the future.  Our registration has expired, and we
+        // have no subscriptions, so it should be dropped.
+        long future = System.currentTimeMillis() + 2 * PushManager.TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS;
+        manager.startup(future);
+
+        ((ShadowLooper) ShadowExtractor.extract(GeckoBackgroundThread.getHandler().getLooper())).idle();
+        verify(pushClient).unregisterUserAgent(anyString(), anyString());
+        Assert.assertTrue(state.registrations.isEmpty());
+    }
+
+    @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);
+
+        // Now, try well into the future.  Our registration has expired, but we
+        // have subscriptions, so it shouldn't be dropped.
+        long future = System.currentTimeMillis() + 2 * PushManager.TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS;
+        manager.startup(future);
+
+        ((ShadowLooper) ShadowExtractor.extract(GeckoBackgroundThread.getHandler().getLooper())).idle();
+        verify(pushClient, never()).unregisterUserAgent(anyString(), anyString());
+        Assert.assertNotNull(manager.registrationForSubscription(subscription.chid));
+    }
+}