Bug 1243855 - Add Java client for interacting with autopush endpoint service. r?rnewman,sebastian draft
authorNick Alexander <nalexander@mozilla.com>
Fri, 29 Jan 2016 13:47:20 -0800
changeset 327745 9fa4cf97c397e69d36998f74be5d86d2c3a1663d
parent 327232 96f4549cab327edecf945365a6c2637caef1eecf
child 513737 e040beec00f564e2cad8b2d75ea232777209179c
push id10279
push usernalexander@mozilla.com
push dateMon, 01 Feb 2016 18:32:54 +0000
reviewersrnewman, sebastian
bugs1243855
milestone47.0a1
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,402 @@
+/* -*- 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 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;
+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.Utils;
+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;
+
+
+/**
+ * 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 (!parts[parts.length - 1].isEmpty()) {
+            // 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 (!"gcm".equals(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 (senderID.isEmpty()) {
+            // 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, Utils.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,22 @@
+/* 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;
+
+@RunWith(TestRunner.class)
+public class TestAutopushClient {
+    final String serverURL = "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407";
+
+    @Test
+    public void testGetSenderID() throws Exception {
+        final AutopushClient client = new AutopushClient(serverURL, WaitHelper.newSynchronousExecutor());
+        Assert.assertEquals("829133274407", 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());
+    }
+}