Bug 1243855 - Add Java client for interacting with autopush endpoint service. r=rnewman,sebastian
authorNick Alexander <nalexander@mozilla.com>
Fri, 29 Jan 2016 13:47:20 -0800
changeset 319182 900f4a6eebc269410a821a885b505c22213b4fb2
parent 319181 a81960ce939edd6bff30d85fa07350e780901dac
child 319183 e228040a044b7ff7363a178da2cb0b8b42724048
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, sebastian
bugs1243855
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 1243855 - Add Java client for interacting with autopush endpoint service. r=rnewman,sebastian A few notes: the test is live, so I've marked it @Ignore, so that it doesn't run during |mach gradle test|. There's some value in mocking the service endpoint, but this is how I verify that the server works, so it has more value right now as a live test than a mocked test. In the future, that probably won't be true. There are issues running the test locally because Robolectric doesn't provide all the cipher suites we use in GlobalConstants: in particular, the GCM suites aren't supported. This may improve as Robolectric matures, or we may add a work-around in the code (like at http://androidxref.com/4.4.4_r1/xref/libcore/support/src/test/java/libcore/java/security/StandardNames.java#68), or we may add a test-specific flag. For now, I'm not going to address it directly. Finally, I put the code in mobile/android/services, simply because the less that goes into base, the better our build times will be.
mobile/android/base/android-services.mozbuild
mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java
mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java
mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -854,16 +854,20 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     'fxa/sync/FxAccountProfileService.java',
     'fxa/sync/FxAccountSchedulePolicy.java',
     'fxa/sync/FxAccountSyncAdapter.java',
     'fxa/sync/FxAccountSyncDelegate.java',
     'fxa/sync/FxAccountSyncService.java',
     'fxa/sync/FxAccountSyncStatusHelper.java',
     'fxa/sync/SchedulePolicy.java',
     'fxa/SyncStatusListener.java',
+    'push/autopush/AutopushClient.java',
+    'push/autopush/AutopushClientException.java',
+    'push/RegisterUserAgentResponse.java',
+    'push/SubscribeChannelResponse.java',
     'sync/AlreadySyncingException.java',
     'sync/BackoffHandler.java',
     'sync/BadRequiredFieldJSONException.java',
     'sync/CollectionKeys.java',
     'sync/CommandProcessor.java',
     'sync/CommandRunner.java',
     'sync/CredentialException.java',
     'sync/crypto/CryptoException.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java
@@ -0,0 +1,19 @@
+/* -*- 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;
+
+/**
+ * Thin container for a register User-Agent response.
+ */
+public class RegisterUserAgentResponse {
+    public final String uaid;
+    public final String secret;
+
+    public RegisterUserAgentResponse(String uaid, String secret) {
+        this.uaid = uaid;
+        this.secret = secret;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java
@@ -0,0 +1,19 @@
+/* -*- 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;
+
+/**
+ * Thin container for a subscribe channel response.
+ */
+public class SubscribeChannelResponse {
+    public final String channelID;
+    public final String endpoint;
+
+    public SubscribeChannelResponse(String channelID, String endpoint) {
+        this.channelID = channelID;
+        this.endpoint = endpoint;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
@@ -0,0 +1,405 @@
+/* -*- 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.autopush;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.push.RegisterUserAgentResponse;
+import org.mozilla.gecko.push.SubscribeChannelResponse;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.Locale;
+import java.util.concurrent.Executor;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+
+/**
+ * Interact with the autopush endpoint HTTP API.
+ * <p/>
+ * The API is a Mozilla-proprietary interface, and not even specified to Mozilla's usual ad-hoc standards.
+ * This client is written against a work-in-progress, un-deployed upstream commit.
+ */
+public class AutopushClient {
+    protected static final String LOG_TAG = AutopushClient.class.getSimpleName();
+
+    protected static final String ACCEPT_HEADER = "application/json;charset=utf-8";
+    protected static final String TYPE = "gcm";
+
+    protected static final String JSON_KEY_UAID = "uaid";
+    protected static final String JSON_KEY_SECRET = "secret";
+    protected static final String JSON_KEY_CHANNEL_ID = "channelID";
+    protected static final String JSON_KEY_ENDPOINT = "endpoint";
+
+    protected static final String[] REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UAID, JSON_KEY_SECRET, JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT };
+    protected static final String[] REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT };
+
+    public static final String JSON_KEY_CODE = "code";
+    public static final String JSON_KEY_ERRNO = "errno";
+    public static final String JSON_KEY_ERROR = "error";
+    public static final String JSON_KEY_MESSAGE = "message";
+
+    /**
+     * The server's URI.
+     * <p>
+     * We assume throughout that this ends with a trailing slash (and guarantee as
+     * much in the constructor).
+     */
+    public final String serverURI;
+
+    protected final Executor executor;
+
+    public AutopushClient(String serverURI, Executor executor) {
+        if (serverURI == null) {
+            throw new IllegalArgumentException("Must provide a server URI.");
+        }
+        if (executor == null) {
+            throw new IllegalArgumentException("Must provide a non-null executor.");
+        }
+        this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/";
+        if (!this.serverURI.endsWith("/")) {
+            throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI);
+        }
+        this.executor = executor;
+    }
+
+    /**
+     * A legal autopush server URL includes a sender ID embedded into it.  Extract it.
+     *
+     * @return a non-null non-empty sender ID.
+     * @throws AutopushClientException on failure.
+     */
+    public String getSenderIDFromServerURI() throws AutopushClientException {
+        // Turn "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407/" into "829133274407".
+        final String[] parts = serverURI.split("/", -1); // The -1 keeps the trailing empty part.
+        if (parts.length < 3) {
+            throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+        }
+        if (!TextUtils.isEmpty(parts[parts.length - 1])) {
+            // We guarantee a trailing slash, so we should always have an empty part at the tail.
+            throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+        }
+        if (!TextUtils.equals("gcm", parts[parts.length - 3])) {
+            // We should always have /gcm/senderID/.
+            throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+        }
+        final String senderID = parts[parts.length - 2];
+        if (TextUtils.isEmpty(senderID)) {
+            // Something is horribly wrong -- we have /gcm//.  Abort.
+            throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+        }
+        return senderID;
+    }
+
+    /**
+     * Process a typed value extracted from a successful response (in an
+     * endpoint-dependent way).
+     */
+    public interface RequestDelegate<T> {
+        void handleError(Exception e);
+        void handleFailure(AutopushClientException e);
+        void handleSuccess(T result);
+    }
+
+    /**
+     * Intepret a response from the autopush server.
+     * <p>
+     * Throw an appropriate exception on errors; otherwise, return the response's
+     * status code.
+     *
+     * @return response's HTTP status code.
+     * @throws AutopushClientException
+     */
+    public static int validateResponse(HttpResponse response) throws AutopushClientException {
+        final int status = response.getStatusLine().getStatusCode();
+        if (200 <= status && status <= 299) {
+            return status;
+        }
+        int code;
+        int errno;
+        String error;
+        String message;
+        String info;
+        ExtendedJSONObject body;
+        try {
+            body = new SyncStorageResponse(response).jsonObjectBody();
+            // TODO: The service doesn't do the right thing yet :(
+            // body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class);
+            // body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class);
+            code = body.getLong(JSON_KEY_CODE).intValue();
+            errno = body.getLong(JSON_KEY_ERRNO).intValue();
+            error = body.getString(JSON_KEY_ERROR);
+            message = body.getString(JSON_KEY_MESSAGE);
+        } catch (Exception e) {
+            throw new AutopushClientException.AutopushClientMalformedResponseException(response);
+        }
+        throw new AutopushClientException.AutopushClientRemoteException(response, code, errno, error, message, body);
+    }
+
+    protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) {
+        executor.execute(new Runnable() {
+            @Override
+            public void run() {
+                delegate.handleError(e);
+            }
+        });
+    }
+
+    protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody, final RequestDelegate<T> delegate) {
+        try {
+            if (requestBody == null) {
+                resource.post((HttpEntity) null);
+            } else {
+                resource.post(requestBody);
+            }
+        } catch (Exception e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+    }
+
+    /**
+     * Translate resource callbacks into request callbacks invoked on the provided
+     * executor.
+     * <p>
+     * Override <code>handleSuccess</code> to parse the body of the resource
+     * request and call the request callback. <code>handleSuccess</code> is
+     * invoked via the executor, so you don't need to delegate further.
+     */
+    protected abstract class ResourceDelegate<T> extends BaseResourceDelegate {
+        protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body);
+
+        protected final String secret;
+        protected final RequestDelegate<T> delegate;
+
+        /**
+         * Create a delegate for an un-authenticated resource.
+         */
+        public ResourceDelegate(final Resource resource, final String secret, final RequestDelegate<T> delegate) {
+            super(resource);
+            this.delegate = delegate;
+            this.secret = secret;
+        }
+
+        @Override
+        public AuthHeaderProvider getAuthHeaderProvider() {
+            if (secret != null) {
+                return new BearerAuthHeaderProvider(secret);
+            }
+            return null;
+        }
+
+        @Override
+        public String getUserAgent() {
+            return FxAccountConstants.USER_AGENT;
+        }
+
+        @Override
+        public void handleHttpResponse(HttpResponse response) {
+            try {
+                final int status = validateResponse(response);
+                invokeHandleSuccess(status, response);
+            } catch (AutopushClientException e) {
+                invokeHandleFailure(e);
+            }
+        }
+
+        protected void invokeHandleFailure(final AutopushClientException e) {
+            executor.execute(new Runnable() {
+                @Override
+                public void run() {
+                    delegate.handleFailure(e);
+                }
+            });
+        }
+
+        protected void invokeHandleSuccess(final int status, final HttpResponse response) {
+            executor.execute(new Runnable() {
+                @Override
+                public void run() {
+                    try {
+                        ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody();
+                        ResourceDelegate.this.handleSuccess(status, response, body);
+                    } catch (Exception e) {
+                        delegate.handleError(e);
+                    }
+                }
+            });
+        }
+
+        @Override
+        public void handleHttpProtocolException(final ClientProtocolException e) {
+            invokeHandleError(delegate, e);
+        }
+
+        @Override
+        public void handleHttpIOException(IOException e) {
+            invokeHandleError(delegate, e);
+        }
+
+        @Override
+        public void handleTransportException(GeneralSecurityException e) {
+            invokeHandleError(delegate, e);
+        }
+
+        @Override
+        public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+            super.addHeaders(request, client);
+
+            // The basics.
+            final Locale locale = Locale.getDefault();
+            request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale));
+            request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER);
+        }
+    }
+
+    public void registerUserAgent(final String token, RequestDelegate<RegisterUserAgentResponse> delegate) {
+        BaseResource resource;
+        try {
+            resource = new BaseResource(new URI(serverURI + "registration"));
+        } catch (URISyntaxException e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+
+        resource.delegate = new ResourceDelegate<RegisterUserAgentResponse>(resource, null, delegate) {
+            @Override
+            public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+                try {
+                    body.throwIfFieldsMissingOrMisTyped(REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS, String.class);
+                    final String uaid = body.getString(JSON_KEY_UAID);
+                    final String secret = body.getString(JSON_KEY_SECRET);
+                    delegate.handleSuccess(new RegisterUserAgentResponse(uaid, secret));
+                    return;
+                } catch (Exception e) {
+                    delegate.handleError(e);
+                    return;
+                }
+            }
+        };
+
+        final ExtendedJSONObject body = new ExtendedJSONObject();
+        body.put("type", TYPE);
+        body.put("token", token);
+
+        resource.post(body);
+    }
+
+    public void reregisterUserAgent(final String uaid, final String secret, final String token, RequestDelegate<Void> delegate) {
+        final BaseResource resource;
+        try {
+            resource = new BaseResource(new URI(serverURI + "registration/" + uaid));
+        } catch (Exception e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+
+        resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+            @Override
+            public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+                try {
+                    delegate.handleSuccess(null);
+                    return;
+                } catch (Exception e) {
+                    delegate.handleError(e);
+                    return;
+                }
+            }
+        };
+
+        final ExtendedJSONObject body = new ExtendedJSONObject();
+        body.put("type", TYPE);
+        body.put("token", token);
+
+        resource.put(body);
+    }
+
+
+    public void subscribeChannel(final String uaid, final String secret, RequestDelegate<SubscribeChannelResponse> delegate) {
+        final BaseResource resource;
+        try {
+            resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription"));
+        } catch (Exception e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+
+        resource.delegate = new ResourceDelegate<SubscribeChannelResponse>(resource, secret, delegate) {
+            @Override
+            public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+                try {
+                    body.throwIfFieldsMissingOrMisTyped(REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS, String.class);
+                    final String channelID = body.getString(JSON_KEY_CHANNEL_ID);
+                    final String endpoint = body.getString(JSON_KEY_ENDPOINT);
+                    delegate.handleSuccess(new SubscribeChannelResponse(channelID, endpoint));
+                    return;
+                } catch (Exception e) {
+                    delegate.handleError(e);
+                    return;
+                }
+            }
+        };
+
+        final ExtendedJSONObject body = new ExtendedJSONObject();
+        resource.post(body);
+    }
+
+    public void unsubscribeChannel(final String uaid, final String secret, final String channelID, RequestDelegate<Void> delegate) {
+        final BaseResource resource;
+        try {
+            resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription/" + channelID));
+        } catch (Exception e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+
+        resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+            @Override
+            public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+                delegate.handleSuccess(null);
+            }
+        };
+
+        resource.delete();
+    }
+
+    public void unregisterUserAgent(final String uaid, final String secret, RequestDelegate<Void> delegate) {
+        final BaseResource resource;
+        try {
+            resource = new BaseResource(new URI(serverURI + "registration/" + uaid));
+        } catch (Exception e) {
+            invokeHandleError(delegate, e);
+            return;
+        }
+
+        resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+            @Override
+            public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+                delegate.handleSuccess(null);
+            }
+        };
+
+        resource.delete();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
@@ -0,0 +1,68 @@
+/* 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.autopush;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class AutopushClientException extends Exception {
+    private static final long serialVersionUID = 7953459541558266500L;
+
+    public AutopushClientException(String detailMessage) {
+        super(detailMessage);
+    }
+
+    public AutopushClientException(Exception e) {
+        super(e);
+    }
+
+    public static class AutopushClientRemoteException extends AutopushClientException {
+        private static final long serialVersionUID = 2209313149952001000L;
+
+        public final HttpResponse response;
+        public final long httpStatusCode;
+        public final long apiErrorNumber;
+        public final String error;
+        public final String message;
+        public final ExtendedJSONObject body;
+
+        public AutopushClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, ExtendedJSONObject body) {
+            super(new HTTPFailureException(new SyncStorageResponse(response)));
+            if (body == null) {
+                throw new IllegalArgumentException("body must not be null");
+            }
+            this.response = response;
+            this.httpStatusCode = httpStatusCode;
+            this.apiErrorNumber = apiErrorNumber;
+            this.error = error;
+            this.message = message;
+            this.body = body;
+        }
+
+        @Override
+        public String toString() {
+            return "<AutopushClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">";
+        }
+
+        public boolean isInvalidAuthentication() {
+            return httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
+        }
+
+        public boolean isNotFound() {
+            return httpStatusCode == HttpStatus.SC_NOT_FOUND;
+        }
+    }
+
+    public static class AutopushClientMalformedResponseException extends AutopushClientRemoteException {
+        private static final long serialVersionUID = 2209313149952001909L;
+
+        public AutopushClientMalformedResponseException(HttpResponse response) {
+            super(response, 0, 999, "Response malformed", "Response malformed", new ExtendedJSONObject());
+        }
+    }
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
@@ -2,16 +2,17 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.background.testhelpers;
 
 import org.mozilla.gecko.background.common.log.Logger;
 
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Executor;
 import java.util.concurrent.TimeUnit;
 
 /**
  * Implements waiting for asynchronous test events.
  *
  * Call WaitHelper.getTestWaiter() to get the unique instance.
  *
  * Call performWait(runnable) to execute runnable synchronously.
@@ -163,9 +164,19 @@ public class WaitHelper {
 
   public static void resetTestWaiter() {
     singleWaiter = new WaitHelper();
   }
 
   public boolean isIdle() {
     return queue.isEmpty();
   }
+
+  public static Executor newSynchronousExecutor() {
+    return new Executor() {
+
+      @Override
+      public void execute(Runnable runnable) {
+        runnable.run();
+      }
+    };
+  }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push.autopush.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.push.autopush.AutopushClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+
+@RunWith(TestRunner.class)
+public class TestAutopushClient {
+    @Test
+    public void testGetSenderID() throws Exception {
+        final AutopushClient client = new AutopushClient("https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407",
+                WaitHelper.newSynchronousExecutor());
+        Assert.assertEquals("829133274407", client.getSenderIDFromServerURI());
+    }
+
+    @Test(expected=AutopushClientException.class)
+    public void testGetNoSenderID() throws Exception {
+        final AutopushClient client = new AutopushClient("https://updates-autopush-dev.stage.mozaws.net/v1/gcm",
+                WaitHelper.newSynchronousExecutor());
+        client.getSenderIDFromServerURI();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push.autopush.test;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+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.AutopushClient.RequestDelegate;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * This test straddles an awkward line: it uses Mockito, but doesn't actually mock the service
+ * endpoint.  That's why it's a <b>live</b> test: most of its value is checking that the client
+ * implementation and the upstream server implementation are corresponding correctly.
+ */
+@RunWith(TestRunner.class)
+@Ignore("Live test that requires network connection -- remove this line to run this test.")
+public class TestLiveAutopushClient {
+    final String serverURL = "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407";
+
+    protected AutopushClient client;
+
+    @Before
+    public void setUp() throws Exception {
+        BaseResource.rewriteLocalhost = false;
+        client = new AutopushClient(serverURL, WaitHelper.newSynchronousExecutor());
+    }
+
+    protected <T> T assertSuccess(RequestDelegate<T> delegate, Class<T> klass) {
+        verify(delegate, never()).handleError(any(Exception.class));
+        verify(delegate, never()).handleFailure(any(AutopushClientException.class));
+
+        final ArgumentCaptor<T> register = ArgumentCaptor.forClass(klass);
+        verify(delegate).handleSuccess(register.capture());
+
+        return register.getValue();
+    }
+
+    protected <T> AutopushClientException assertFailure(RequestDelegate<T> delegate, Class<T> klass) {
+        verify(delegate, never()).handleError(any(Exception.class));
+        verify(delegate, never()).handleSuccess(any(klass));
+
+        final ArgumentCaptor<AutopushClientException> failure = ArgumentCaptor.forClass(AutopushClientException.class);
+        verify(delegate).handleFailure(failure.capture());
+
+        return failure.getValue();
+    }
+
+    @Test
+    public void testUserAgent() throws Exception {
+        final RequestDelegate<RegisterUserAgentResponse> registerDelegate = mock(RequestDelegate.class);
+        client.registerUserAgent(Utils.generateGuid(), registerDelegate);
+
+        final RegisterUserAgentResponse registerResponse = assertSuccess(registerDelegate, RegisterUserAgentResponse.class);
+        Assert.assertNotNull(registerResponse);
+        Assert.assertNotNull(registerResponse.uaid);
+        Assert.assertNotNull(registerResponse.secret);
+
+        // Reregistering with a new GUID should succeed.
+        final RequestDelegate<Void> reregisterDelegate = mock(RequestDelegate.class);
+        client.reregisterUserAgent(registerResponse.uaid, registerResponse.secret, Utils.generateGuid(), reregisterDelegate);
+
+        Assert.assertNull(assertSuccess(reregisterDelegate, Void.class));
+
+        // Unregistering should succeed.
+        final RequestDelegate<Void> unregisterDelegate = mock(RequestDelegate.class);
+        client.unregisterUserAgent(registerResponse.uaid, registerResponse.secret, unregisterDelegate);
+
+        Assert.assertNull(assertSuccess(unregisterDelegate, Void.class));
+
+        // Trying to unregister a second time should give a 404.
+        final RequestDelegate<Void> reunregisterDelegate = mock(RequestDelegate.class);
+        client.unregisterUserAgent(registerResponse.uaid, registerResponse.secret, reunregisterDelegate);
+
+        final AutopushClientException failureException = assertFailure(reunregisterDelegate, Void.class);
+        Assert.assertThat(failureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+        Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) failureException).isNotFound());
+    }
+
+    @Test
+    public void testChannel() throws Exception {
+        final RequestDelegate<RegisterUserAgentResponse> registerDelegate = mock(RequestDelegate.class);
+        client.registerUserAgent(Utils.generateGuid(), registerDelegate);
+
+        final RegisterUserAgentResponse registerResponse = assertSuccess(registerDelegate, RegisterUserAgentResponse.class);
+        Assert.assertNotNull(registerResponse);
+        Assert.assertNotNull(registerResponse.uaid);
+        Assert.assertNotNull(registerResponse.secret);
+
+        // We should be able to subscribe to a channel.
+        final RequestDelegate<SubscribeChannelResponse> subscribeDelegate = mock(RequestDelegate.class);
+        client.subscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeDelegate);
+
+        final SubscribeChannelResponse subscribeResponse = assertSuccess(subscribeDelegate, SubscribeChannelResponse.class);
+        Assert.assertNotNull(subscribeResponse);
+        Assert.assertNotNull(subscribeResponse.channelID);
+        Assert.assertNotNull(subscribeResponse.endpoint);
+        Assert.assertThat(subscribeResponse.endpoint, startsWith(FxAccountUtils.getAudienceForURL(serverURL)));
+
+        // And we should be able to unsubscribe.
+        final RequestDelegate<Void> unsubscribeDelegate = mock(RequestDelegate.class);
+        client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, unsubscribeDelegate);
+
+        Assert.assertNull(assertSuccess(unsubscribeDelegate, Void.class));
+
+        // Trying to unsubscribe a second time should give a 404.
+        final RequestDelegate<Void> reunsubscribeDelegate = mock(RequestDelegate.class);
+        client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, reunsubscribeDelegate);
+
+        final AutopushClientException reunsubscribeFailureException = assertFailure(reunsubscribeDelegate, Void.class);
+        Assert.assertThat(reunsubscribeFailureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+        Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) reunsubscribeFailureException).isNotFound());
+
+        // Trying to unsubscribe from a non-existent channel should give a 404.  Right now it gives a 401!
+        final RequestDelegate<Void> badUnsubscribeDelegate = mock(RequestDelegate.class);
+        client.unsubscribeChannel(registerResponse.uaid + "BAD", registerResponse.secret, subscribeResponse.channelID, badUnsubscribeDelegate);
+
+        final AutopushClientException badUnsubscribeFailureException = assertFailure(badUnsubscribeDelegate, Void.class);
+        Assert.assertThat(badUnsubscribeFailureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+        Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) badUnsubscribeFailureException).isInvalidAuthentication());
+    }
+}