Bug 1207714 - Implement push service; integrate against Gecko. r?rnewman,kitcambridge draft
authorNick Alexander <nalexander@mozilla.com>
Thu, 25 Feb 2016 10:05:43 -0800
changeset 334706 294f0f6b545e15f3bcef5343342f9e39c5ada0b1
parent 334705 b3e5570ec3db5454f72c8fd33041831a85aba2d0
child 334707 601a08a1b52a792c0b02ac0126758b6c512d37af
push id11614
push usernalexander@mozilla.com
push dateThu, 25 Feb 2016 22:06:17 +0000
reviewersrnewman, kitcambridge
bugs1207714
milestone47.0a1
Bug 1207714 - Implement push service; integrate against Gecko. r?rnewman,kitcambridge MozReview-Commit-ID: JfFOuGc57m2
dom/push/PushService.jsm
dom/push/PushServiceAndroidGCM.jsm
dom/push/moz.build
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/FennecManifest_permissions.xml.in
mobile/android/base/GcmAndroidManifest_permissions.xml.in
mobile/android/base/GcmAndroidManifest_services.xml.in
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java
mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java
mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java
mobile/android/base/java/org/mozilla/gecko/push/PushService.java
mobile/android/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
mobile/android/base/moz.build
mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -5,29 +5,37 @@
 
 "use strict";
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 const Cr = Components.results;
 
-const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
-const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm");
-const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm");
 const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
 
-// Currently supported protocols: WebSocket.
-const CONNECTION_PROTOCOLS = [PushServiceWebSocket, PushServiceHttp2];
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceAndroidGCM",
+                                  "resource://gre/modules/PushServiceAndroidGCM.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceHttp2",
+                                  "resource://gre/modules/PushServiceHttp2.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceWebSocket",
+                                  "resource://gre/modules/PushServiceWebSocket.jsm");
+
+// Order matters!  First in the list gets the first opportunity to serve.
+const CONNECTION_PROTOCOLS = AppConstants.MOZ_WIDGET_TOOLKIT != 'android'
+        ? [PushServiceWebSocket, PushServiceHttp2]
+        : [PushServiceAndroidGCM];
 
 XPCOMUtils.defineLazyModuleGetter(this, "AlarmService",
                                   "resource://gre/modules/AlarmService.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gContentSecurityManager",
                                    "@mozilla.org/contentsecuritymanager;1",
                                    "nsIContentSecurityManager");
 
new file mode 100644
--- /dev/null
+++ b/dom/push/PushServiceAndroidGCM.jsm
@@ -0,0 +1,298 @@
+/* jshint moz: true, esnext: true */
+/* 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/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
+Cu.import("resource://gre/modules/Messaging.jsm"); /*global: Services */
+Cu.import("resource://gre/modules/Services.jsm"); /*global: Services */
+Cu.import("resource://gre/modules/Preferences.jsm"); /*global: Preferences */
+Cu.import("resource://gre/modules/Promise.jsm"); /*global: Promise */
+Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global: XPCOMUtils */
+
+const Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("Push");
+
+const {
+  PushCrypto,
+  base64UrlDecode,
+  concatArray,
+  getEncryptionKeyParams,
+  getEncryptionParams,
+} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+
+this.EXPORTED_SYMBOLS = ["PushServiceAndroidGCM"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+  let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+  return new ConsoleAPI({
+    dump: Log.i,
+    maxLogLevelPref: "dom.push.loglevel",
+    prefix: "PushServiceAndroidGCM",
+  });
+});
+
+const kPUSHANDROIDGCMDB_DB_NAME = "pushAndroidGCM";
+const kPUSHANDROIDGCMDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
+const kPUSHANDROIDGCMDB_STORE_NAME = "pushAndroidGCM";
+
+const prefs = new Preferences("dom.push.");
+
+/**
+ * The implementation of WebPush push backed by Android's GCM
+ * delivery.
+ */
+this.PushServiceAndroidGCM = {
+  _mainPushService: null,
+  _serverURI: null,
+
+  newPushDB: function() {
+    return new PushDB(kPUSHANDROIDGCMDB_DB_NAME,
+                      kPUSHANDROIDGCMDB_DB_VERSION,
+                      kPUSHANDROIDGCMDB_STORE_NAME,
+                      "channelID",
+                      PushRecordAndroidGCM);
+  },
+
+  serviceType: function() {
+    return "AndroidGCM";
+  },
+
+  validServerURI: function(serverURI) {
+    if (!serverURI) {
+      return false;
+    }
+
+    if (serverURI.scheme == "https") {
+      return true;
+    }
+    if (prefs.get("debug") && serverURI.scheme == "http") {
+      // Accept HTTP endpoints when debugging.
+      return true;
+    }
+    console.info("Unsupported Android GCM dom.push.serverURL scheme", serverURI.scheme);
+    return false;
+  },
+
+  observe: function(subject, topic, data) {
+    if (topic == "nsPref:changed") {
+      if (data == "dom.push.debug") {
+        // Reconfigure.
+        let debug = prefs.get("debug");
+        console.info("Debug parameter changed; updating configuration with new debug", debug);
+        this._configure(this._serverURI, debug);
+        return;
+      }
+    }
+
+    if (topic == "PushServiceAndroidGCM:ReceivedPushMessage") {
+      // XXX use Messaging.jsm for this.
+      if (this._mainPushService == null) {
+        // Shouldn't ever happen, but let's be careful.  XXX really?
+        console.error("No main PushService!  Dropping message.");
+        return;
+      }
+      if (!data) {
+        console.error("No data from Java!  Dropping message.");
+        return;
+      }
+      data = JSON.parse(data);
+      console.debug("ReceivedPushMessage with data", data);
+
+      // Default is no data (and no encryption).
+      let message = null;
+      let cryptoParams = null;
+
+      if (data.message && data.enc && data.enckey) {
+        // But we might have encrypted data.
+        let keymap = getEncryptionKeyParams(data.enckey);
+        if (!keymap) {
+          console.warn("ReceivedPushMessage with encrypted data but no keymap!  Dropping message.");
+          return;
+        }
+        let enc = getEncryptionParams(data.enc);
+        if (!enc || !enc.keyid) {
+          console.warn("ReceivedPushMessage with encrypted data but no encryption parameters!  Dropping message.");
+          return;
+        }
+        let dh = keymap[enc.keyid];
+        let salt = enc.salt;
+        let rs = (enc.rs)? parseInt(enc.rs, 10) : 4096;
+        if (!dh || !salt || isNaN(rs) || (rs <= 1)) {
+          console.warn("ReceivedPushMessage with encrypted data and bad encryption parameters!  Dropping message.");
+          return;
+        }
+        cryptoParams = {
+          dh: dh,
+          salt: salt,
+          rs: rs
+        };
+        // Ciphertext is (urlsafe) Base 64 encoded.
+        message = base64UrlDecode(data.message);
+      }
+
+      console.debug("Delivering message to main PushService:", message, cryptoParams);
+      this._mainPushService.receivedPushMessage(
+        data.channelID, message, cryptoParams, (record) => {
+          // Always update the stored record.
+          return record;
+        });
+      return;
+    }
+  },
+
+  _configure: function(serverURL, debug) {
+    return Messaging.sendRequestForResult({
+      type: "PushServiceAndroidGCM:Configure",
+      endpoint: serverURL.spec,
+      debug: debug,
+    });
+  },
+
+  init: function(options, mainPushService, serverURL) {
+    console.debug("init()");
+    this._mainPushService = mainPushService;
+    this._serverURI = serverURL;
+
+    prefs.observe("debug", this);
+    Services.obs.addObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage", false);
+
+    return this._configure(serverURL, prefs.get("debug"));
+  },
+
+  uninit: function() {
+    console.debug("uninit()");
+    this._mainPushService = null;
+    Services.obs.removeObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage");
+    prefs.ignore("debug", this);
+  },
+
+  onAlarmFired: function() {
+    // No action required.
+  },
+
+  connect: function(records) {
+    console.debug("connect:", records);
+    // It's possible for the registration or subscriptions backing the
+    // PushService to not be registered with the underlying AndroidPushService.
+    // Expire those that are unrecognized.
+    return Messaging.sendRequestForResult({
+      type: "PushServiceAndroidGCM:DumpSubscriptions",
+    })
+    .then(subscriptions => {
+      console.debug("connect:", subscriptions);
+      // subscriptions maps chid => subscription data.
+      return Promise.all(records.map(record => {
+        if (subscriptions.hasOwnProperty(record.channelID)) {
+          console.debug("connect:", "hasOwnProperty", record.channelID);
+          return Promise.resolve();
+        }
+        console.debug("connect:", "!hasOwnProperty", record.channelID);
+        // Subscription is known to PushService.jsm but not to AndroidPushService.  Drop it.
+        return this._mainPushService.dropRegistrationAndNotifyApp(record.keyID)
+          .catch(error => {
+            console.error("connect: Error dropping registration", record.keyID, error);
+          });
+      }));
+    });
+  },
+
+  isConnected: function() {
+    return this._mainPushService != null;
+  },
+
+  disconnect: function() {
+    console.debug("disconnect");
+  },
+
+  request: function(action, record) {
+    switch (action) {
+    case "register":
+      console.debug("register:", record);
+      return this._register(record);
+    case "unregister":
+      console.debug("unregister: ", record);
+      return this._unregister(record);
+    default:
+      console.debug("Ignoring unrecognized request action:", action);
+    }
+  },
+
+  _register: function(record) {
+    let ctime = Date.now();
+    // Caller handles errors.
+    return Messaging.sendRequestForResult({
+      type: "PushServiceAndroidGCM:SubscribeChannel",
+    }).then(data => {
+      console.debug("Got data:", data);
+      return PushCrypto.generateKeys()
+        .then(exportedKeys =>
+          new PushRecordAndroidGCM({
+            // Straight from autopush.
+            channelID: data.channelID,
+            pushEndpoint: data.endpoint,
+            // Common to all PushRecord implementations.
+            scope: record.scope,
+            originAttributes: record.originAttributes,
+            quota: record.maxQuota,
+            ctime: ctime,
+            // Cryptography!
+            p256dhPublicKey: exportedKeys[0],
+            p256dhPrivateKey: exportedKeys[1]
+          })
+      );
+    });
+  },
+
+  _unregister: function(record) {
+    return Messaging.sendRequestForResult({
+      type: "PushServiceAndroidGCM:UnsubscribeChannel",
+      channelID: record.channelID
+    });
+  }
+};
+
+function PushRecordAndroidGCM(record) {
+  PushRecord.call(this, record);
+  this.channelID = record.channelID;
+}
+
+PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
+  keyID: {
+    get() {
+      return this.channelID;
+    },
+  },
+});
+
+// XXX -- should we expose the channelID in this way?  To both places?
+PushRecordAndroidGCM.prototype.toRegistration = function() {
+  let registration = PushRecord.prototype.toRegistration.call(this);
+  registration.channelID = this.channelID;
+  return registration;
+};
+
+PushRecordAndroidGCM.prototype.toRegister = function() {
+  let register = PushRecord.prototype.toRegister.call(this);
+  register.channelID = this.channelID;
+  return register;
+};
+
+// Test cases:
+// 1. Change debug pref in Gecko.
+// 2. Change serverURL in Gecko.
+// 3. Change GCM token with Gecko not running.
+// We should be able to update registration without impacting subscriptions.
+// 4. Change sender ID with Gecko not running.
+// We must drop subscriptions.  Gecko should witness missing subscriptions and expire them when it starts.
+// 5. Force 401 Bad auth errors.
+// We must drop registration (and hence subscriptions).  Gecko should witness missing subscriptions and expire them when it starts.
+// 6. Registration doesn't complete until we have a subscription.
+// 7. Verify that we stop registering (and accepting pushes) when we have no subscriptions.
--- a/dom/push/moz.build
+++ b/dom/push/moz.build
@@ -13,16 +13,22 @@ EXTRA_JS_MODULES += [
     'PushCrypto.jsm',
     'PushDB.jsm',
     'PushRecord.jsm',
     'PushService.jsm',
     'PushServiceHttp2.jsm',
     'PushServiceWebSocket.jsm',
 ]
 
+if CONFIG['MOZ_BUILD_APP'] == 'mobile/android':
+    # Fennec only for now.
+    EXTRA_JS_MODULES += [
+        'PushServiceAndroidGCM.jsm',
+    ]
+
 MOCHITEST_MANIFESTS += [
     'test/mochitest.ini',
 ]
 
 XPCSHELL_TESTS_MANIFESTS += [
     'test/xpcshell/xpcshell.ini',
 ]
 
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -372,10 +372,14 @@
 
 #ifdef MOZ_ANDROID_SEARCH_ACTIVITY
 #include ../search/manifests/SearchAndroidManifest_services.xml.in
 #endif
 #ifdef MOZ_ANDROID_MLS_STUMBLER
 #include ../stumbler/manifests/StumblerManifest_services.xml.in
 #endif
 
+#ifdef MOZ_ANDROID_GCM
+#include GcmAndroidManifest_services.xml.in
+#endif
+
     </application>
 </manifest>
--- a/mobile/android/base/FennecManifest_permissions.xml.in
+++ b/mobile/android/base/FennecManifest_permissions.xml.in
@@ -5,16 +5,20 @@
      they can be easily shared between the two APKs. -->
 
 #include ../services/manifests/FxAccountAndroidManifest_permissions.xml.in
 
 #ifdef MOZ_ANDROID_SEARCH_ACTIVITY
 #include ../search/manifests/SearchAndroidManifest_permissions.xml.in
 #endif
 
+#ifdef MOZ_ANDROID_GCM
+#include GcmAndroidManifest_permissions.xml.in
+#endif
+
     <!-- A signature level permission specific to each Firefox version (Android
          package name, e.g., org.mozilla.firefox).  Use this permission to
          broadcast securely within a single Firefox version.  This needs to
          agree with GlobalConstants.PER_ANDROID_PACKAGE_PERMISSION. -->
     <permission
         android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE"
         android:protectionLevel="signature"/>
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/GcmAndroidManifest_permissions.xml.in
@@ -0,0 +1,4 @@
+    <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
+    <!-- Avoid a linter warning by not double-including WAKE_LOCK.
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    -->
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/GcmAndroidManifest_services.xml.in
@@ -0,0 +1,29 @@
+        <!-- Handle GCM registration updates from on-device Google Play Services. -->
+        <service
+            android:name="org.mozilla.gecko.gcm.GcmInstanceIDListenerService"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="com.google.android.gms.iid.InstanceID"/>
+            </intent-filter>
+        </service>
+
+        <!-- Provided by on-device Google Play Services.  Directs inbound messages to internal listener service. -->
+        <receiver
+            android:name="com.google.android.gms.gcm.GcmReceiver"
+            android:exported="true"
+            android:permission="com.google.android.c2dm.permission.SEND">
+            <intent-filter>
+                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+                <action android:name="com.google.android.c2dm.intent.REGISTRATION" />
+                <category android:name="@ANDROID_PACKAGE_NAME@" />
+            </intent-filter>
+        </receiver>
+
+        <!-- Handle messages directed by the GCM receiver. -->
+        <service
+            android:name="org.mozilla.gecko.gcm.GcmMessageListenerService"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+            </intent-filter>
+        </service>
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -1,36 +1,35 @@
 /* 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;
 
-import org.mozilla.gecko.AdjustConstants;
-import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.db.BrowserContract;
-import org.mozilla.gecko.db.BrowserDB;
-import org.mozilla.gecko.db.LocalBrowserDB;
-import org.mozilla.gecko.dlc.DownloadContentService;
-import org.mozilla.gecko.home.HomePanelsManager;
-import org.mozilla.gecko.lwt.LightweightTheme;
-import org.mozilla.gecko.mdns.MulticastDNSManager;
-import org.mozilla.gecko.util.Clipboard;
-import org.mozilla.gecko.util.HardwareUtils;
-import org.mozilla.gecko.util.ThreadUtils;
-
 import android.app.Application;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.res.Configuration;
 import android.os.SystemClock;
 import android.util.Log;
 
 import com.squareup.leakcanary.LeakCanary;
 
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.home.HomePanelsManager;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.mdns.MulticastDNSManager;
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
 import java.io.File;
 
 public class GeckoApplication extends Application 
     implements ContextGetter {
     private static final String LOG_TAG = "GeckoApplication";
 
     private static volatile GeckoApplication instance;
 
@@ -161,16 +160,38 @@ public class GeckoApplication extends Ap
                 return new LocalBrowserDB(profileName);
             }
         });
 
         GeckoService.register();
 
         super.onCreate();
 
+        if (AppConstants.MOZ_ANDROID_GCM) {
+            // android.os.Process.myUserHandle().
+            // TODO: only run in main process.
+            ThreadUtils.postToBackgroundThread(new Runnable() {
+                @Override
+                public void run() {
+                    // It's fine to throw GCM initialization onto a background thread; the registration process requires
+                    // network access, so is naturally asynchronous.  This, of course, races against Gecko page load of
+                    // content requiring GCM-backed services, like Web Push.  There's nothing to be done here.
+                    PushService.createInstance(context);
+                    PushService.register();
+
+                    try {
+                        PushService.getInstance().onStartup();
+                    } catch (Exception e) {
+                        Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+                        return;
+                    }
+                }
+            });
+        }
+
         if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
             DownloadContentService.startStudy(this);
         }
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java
@@ -0,0 +1,37 @@
+/* -*- 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.gcm;
+
+import android.util.Log;
+
+import com.google.android.gms.iid.InstanceIDListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service is notified by the on-device Google Play Services library if an
+ * in-use token needs to be updated.  We simply pass through to AndroidPushService.
+ */
+public class GcmInstanceIDListenerService extends InstanceIDListenerService {
+    private static final String LOG_TAG = "GeckoGCMInstanceID";
+
+    /**
+     * Called if InstanceID token is updated. This may occur if the security of
+     * the previous token had been compromised. This call is initiated by the
+     * InstanceID provider.
+     */
+    @Override
+    public void onTokenRefresh() {
+        Log.d("GeckoPushGCM", "Token refresh request received.  Processing on background thread.");
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                PushService.getInstance().onRefresh();
+            }
+        });
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java
@@ -0,0 +1,37 @@
+/* -*- 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.gcm;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.android.gms.gcm.GcmListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service actually handles messages directed from the on-device Google
+ * Play Services package.  We simply route them to the AndroidPushService.
+ */
+public class GcmMessageListenerService extends GcmListenerService {
+    /**
+     * Called when message is received.
+     *
+     * @param from SenderID of the sender.
+     * @param bundle Data bundle containing message data as key/value pairs.
+     */
+    @Override
+    public void onMessageReceived(final String from, final Bundle bundle) {
+        Log.d("GeckoPushGCM", "Message received.  Processing on background thread.");
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                PushService.getInstance().onMessageReceived(bundle);
+            }
+        });
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java
@@ -0,0 +1,87 @@
+/* -*- 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.gcm;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.gms.gcm.GoogleCloudMessaging;
+import com.google.android.gms.iid.InstanceID;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+/**
+ * TODO: class comments.
+ */
+public class GcmTokenClient {
+    private static final String LOG_TAG = GcmTokenClient.class.getSimpleName();
+    private final Context context;
+
+    // Map senderID -> token.
+    private final HashMap<String, String> tokenCache = new HashMap<>();
+
+    private InstanceID instanceID = null; // Lazily initialized.
+
+    public GcmTokenClient(Context context) {
+        this.context = context;
+    }
+
+    /**
+     * Check the device to make sure it has the Google Play Services APK.
+     * @param context Android context.
+     */
+    protected void ensurePlayServices(Context context) throws NeedsGooglePlayServicesException {
+        final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+        int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
+        if (resultCode != ConnectionResult.SUCCESS) {
+            Logger.warn(LOG_TAG, "This device does not support GCM! isGooglePlayServicesAvailable returned: " + resultCode);
+            Logger.warn(LOG_TAG, "isGooglePlayServicesAvailable message: " + apiAvailability.getErrorString(resultCode));
+            throw new NeedsGooglePlayServicesException(resultCode);
+        }
+    }
+
+    // Note: https://developers.google.com/cloud-messaging/android/legacy-regid and App version.
+    public @NonNull String getToken(@NonNull String senderID) throws NeedsGooglePlayServicesException, IOException {
+        ensurePlayServices(this.context);
+
+        String token = tokenCache.get(senderID);
+        if (token != null) {
+            Logger.info(LOG_TAG, "Cached GCM token exists: " + token);
+            return token;
+        }
+
+        Logger.info(LOG_TAG, "Cached GCM token does not exist; requesting new token with sender ID: " + senderID);
+
+        if (instanceID == null) {
+            instanceID = InstanceID.getInstance(context);
+        }
+
+        token = instanceID.getToken(senderID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
+        tokenCache.put(senderID, token);
+        return token;
+    }
+
+    public class NeedsGooglePlayServicesException extends Exception {
+        private static final long serialVersionUID = 4132853166L;
+
+        private final int resultCode;
+
+        NeedsGooglePlayServicesException(int resultCode) {
+            super();
+            this.resultCode = resultCode;
+        }
+
+        public void showErrorNotification() {
+            final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+            apiAvailability.showErrorNotification(context, resultCode);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -0,0 +1,314 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
+ * <p/>
+ * This class services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud
+ * Messaging requests.
+ * <p/>
+ * It's worth noting that we allow the DOM push API in restricted profiles.
+ */
+public class PushService implements BundleEventListener {
+    private static final String LOG_TAG = "GeckoPushService";
+
+    public static final String SERVICE_WEBPUSH = "webpush"; // TODO: coalesce constants.
+    public static final String SERVICE_SYNC = "sync";
+
+    private static PushService sInstance;
+
+    private static final String[] GECKO_EVENTS = new String[]{
+            "PushServiceAndroidGCM:Configure",
+            "PushServiceAndroidGCM:DumpRegistration",
+            "PushServiceAndroidGCM:DumpSubscriptions",
+            "PushServiceAndroidGCM:RegisterUserAgent",
+            "PushServiceAndroidGCM:UnregisterUserAgent",
+            "PushServiceAndroidGCM:SubscribeChannel",
+            "PushServiceAndroidGCM:UnsubscribeChannel",
+    };
+
+    public static synchronized PushService getInstance() {
+        if (sInstance == null) {
+            throw new IllegalStateException("PushService not yet created!");
+        }
+        return sInstance;
+    }
+
+    public static synchronized PushService createInstance(Context context) {
+        if (sInstance != null) {
+            throw new IllegalStateException("PushService already created!");
+        }
+        sInstance = new PushService(context);
+        return sInstance;
+    }
+
+    protected final PushManager pushManager;
+
+    public PushService(Context context) {
+        pushManager = new PushManager(new PushState(context, "pushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
+            @Override
+            public PushClient getPushClient(String autopushEndpoint, boolean debug) {
+                return new PushClient(autopushEndpoint);
+            }
+        });
+    }
+
+
+    public void onStartup() {
+        Log.i(LOG_TAG, "Starting up.");
+        ThreadUtils.assertOnBackgroundThread();
+
+        try {
+            pushManager.startup(System.currentTimeMillis());
+        } catch (Exception e) {
+            Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+            return;
+        }
+    }
+
+    public void onRefresh() {
+        Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; running startup again.");
+        ThreadUtils.assertOnBackgroundThread();
+
+        try {
+            pushManager.startup(System.currentTimeMillis());
+        } catch (Exception e) {
+            Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+            return;
+        }
+    }
+
+    public void onMessageReceived(final @NonNull Bundle bundle) {
+        Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
+        ThreadUtils.assertOnBackgroundThread();
+
+        final String chid = bundle.getString("chid");
+        if (chid == null) {
+            Log.w(LOG_TAG, "No chid found; ignoring message.");
+            return;
+        }
+
+        final PushRegistration registration = pushManager.registrationForSubscription(chid);
+        if (registration == null) {
+            Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message.");
+            return;
+        }
+
+        final PushSubscription subscription = registration.getSubscription(chid);
+        if (subscription == null) {
+            // This should never happen.  There's not much to be done; in the future, perhaps we
+            // could try to drop the remote subscription?
+            Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message.");
+            return;
+        }
+
+        Log.i(LOG_TAG, "Message directed to service: " + subscription.service);
+
+        if (SERVICE_SYNC.equals(subscription.service)) {
+            // Nothing yet.
+            return;
+        }
+
+        if (SERVICE_WEBPUSH.equals(subscription.service)) {
+            if (subscription.serviceData == null) {
+                Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message.");
+                return;
+            }
+
+            final String profileName = subscription.serviceData.optString("profileName", null);
+            final String profilePath = subscription.serviceData.optString("profilePath", null);
+            if (profileName == null || profilePath == null) {
+                Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message.");
+                return;
+            }
+
+            if (!GeckoThread.isRunning()) {
+                Log.w(LOG_TAG, "dom/push message received but no Gecko thread is running; ignoring message.");
+                return;
+            }
+
+            final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
+            if (geckoInterface == null) {
+                Log.w(LOG_TAG, "dom/push message received but no Gecko interface is registered; ignoring message.");
+                return;
+            }
+
+            final GeckoProfile profile = geckoInterface.getProfile();
+            if (profile == null || !profileName.equals(profile.getName()) || !profilePath.equals(profile.getDir().getAbsolutePath())) {
+                Log.w(LOG_TAG, "dom/push message received but Gecko is running with the wrong profile name or path; ignoring message.");
+                return;
+            }
+
+            // DELIVERANCE!
+            final JSONObject data = new JSONObject();
+            try {
+                data.put("channelID", chid);
+                data.put("enc", bundle.getString("enc"));
+                data.put("enckey", bundle.getString("enckey"));
+                data.put("message", bundle.getString("body"));
+            } catch (JSONException e) {
+                Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e);
+                return;
+            }
+
+            Log.i(LOG_TAG, "Delivering dom/push message to Gecko!");
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("PushServiceAndroidGCM:ReceivedPushMessage", data.toString()));
+        }
+    }
+
+    public static void register() {
+        Log.d(LOG_TAG, "Registered listener.");
+        EventDispatcher.getInstance().registerBackgroundThreadListener(getInstance(), GECKO_EVENTS);
+    }
+
+    public static void unregister() {
+        Log.d(LOG_TAG, "Unregistered listener.");
+        EventDispatcher.getInstance().unregisterBackgroundThreadListener(getInstance(), GECKO_EVENTS);
+    }
+
+    @Override
+    public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
+        Log.i(LOG_TAG, "Handling event: " + event);
+        ThreadUtils.assertOnBackgroundThread();
+
+        // We're invoked in response to a Gecko message on a background thread.  We should always
+        // be able to safely retrieve the current Gecko profile.
+        final GeckoProfile geckoProfile = GeckoProfile.get(GeckoAppShell.getApplicationContext());
+
+        // TODO: errors are objects with a .error string field.  Do this in the JSM.
+        if (callback == null) {
+            Log.e(LOG_TAG, "callback must not be null in " + event);
+            return;
+        }
+
+        try {
+            if ("PushServiceAndroidGCM:Configure".equals(event)) {
+                final String endpoint = message.getString("endpoint");
+                if (endpoint == null) {
+                    Log.e(LOG_TAG, "endpoint must not be null in " + event);
+                    return;
+                }
+                final boolean debug = message.getBoolean("debug", false);
+                pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects.
+                callback.sendSuccess(null);
+                return;
+            } else if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
+                // TODO: implement this!
+            } else if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) {
+                try {
+                    final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName());
+
+                    final JSONObject json = new JSONObject();
+                    for (Map.Entry<String, PushSubscription> entry : result.entrySet()) {
+                        json.put(entry.getKey(), entry.getValue().toJSONObject());
+                    }
+                    callback.sendSuccess(json);
+                    return;
+                } catch (JSONException e) {
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                    return;
+                }
+            } else if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
+                try {
+                    final PushRegistration registration = pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis());
+                    callback.sendSuccess(null);
+                    return;
+                } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                    return;
+                }
+            } else if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
+                callback.sendError("Not yet implemented!");
+                return;
+            } else if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
+                final String service = SERVICE_WEBPUSH;
+                final JSONObject serviceData;
+                try {
+                    serviceData = new JSONObject();
+                    serviceData.put("profileName", geckoProfile.getName());
+                    serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
+                } catch (JSONException e) {
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                    return;
+                }
+
+                final PushSubscription subscription;
+                try {
+                    subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, System.currentTimeMillis());
+                } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                    return;
+                }
+
+                final JSONObject json = new JSONObject();
+                try {
+                    json.put("channelID", subscription.chid);
+                    json.put("endpoint", subscription.webpushEndpoint);
+                } catch (JSONException e) {
+                    Log.e(LOG_TAG, "Got exception in " + event, e);
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                    return;
+                }
+                callback.sendSuccess(json);
+            } else if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
+                callback.sendError("Not yet implemented!");
+                return;
+            }
+        } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+            // XXX TODO: improve this.  Can we find a point where the user is *definitely*
+            // interacting with the WebPush?  Perhaps we can show a dialog when interacting with
+            // the Push permissions, and then be more aggressive showing this notification when
+            // we have registrations and subscriptions that can't be advanced.
+            e.showErrorNotification();
+            callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
+        }
+    }
+
+//    /**
+//     * Create and show a simple notification containing the received GCM message.
+//     *
+//     * @param message GCM message received.
+//     */
+//    private void sendNotification(String message) {
+//        final Intent intent = new Intent(this, BrowserApp.class);
+//        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+//        final PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, PendingIntent.FLAG_ONE_SHOT);
+//
+//        final Uri defaultSoundUri= RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
+//        NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
+//                .setSmallIcon(R.drawable.ic_status_logo)
+//                .setContentTitle("GCM Message")
+//                .setContentText(message)
+//                .setAutoCancel(true)
+//                .setSound(defaultSoundUri)
+//                .setContentIntent(pendingIntent);
+//
+//        final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+//        notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
+//    }
+}
--- 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,17 @@ 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 static synchronized Handler getHandler() {
         if (thread == null) {
             startThread(null);
         }
 
         while (handler == null) {
             try {
                 GeckoBackgroundThread.class.wait();
             } catch (final InterruptedException e) {
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -655,16 +655,30 @@ gbjar.extra_jars += [
     CONFIG['ANDROID_SUPPORT_V4_AAR_LIB'],
     CONFIG['ANDROID_SUPPORT_V4_AAR_INTERNAL_LIB'],
     'constants.jar'
 ]
 if CONFIG['MOZ_CRASHREPORTER']:
     gbjar.sources += [ 'java/org/mozilla/gecko/CrashReporter.java' ]
     ANDROID_RES_DIRS += [ 'crashreporter/res' ]
 
+if CONFIG['MOZ_ANDROID_GCM']:
+    gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [
+        'gcm/GcmInstanceIDListenerService.java',
+        'gcm/GcmMessageListenerService.java',
+        'gcm/GcmTokenClient.java',
+        'push/Fetched.java',
+        'push/PushClient.java',
+        'push/PushManager.java',
+        'push/PushRegistration.java',
+        'push/PushService.java',
+        'push/PushState.java',
+        'push/PushSubscription.java',
+    ]]
+
 if (CONFIG['MOZ_ANDROID_MAX_SDK_VERSION']):
     max_sdk_version = int(CONFIG['MOZ_ANDROID_MAX_SDK_VERSION'])
 else:
     max_sdk_version = 999
 
 # Only bother to include new tablet code if we're building for tablet-capable
 # OS releases.
 if max_sdk_version >= 11:
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
@@ -16,16 +16,20 @@ public class AutopushClientException ext
     public AutopushClientException(String detailMessage) {
         super(detailMessage);
     }
 
     public AutopushClientException(Exception e) {
         super(e);
     }
 
+    public boolean isTransientError() {
+        return false;
+    }
+
     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;
@@ -51,16 +55,21 @@ public class AutopushClientException ext
 
         public boolean isInvalidAuthentication() {
             return httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
         }
 
         public boolean isNotFound() {
             return httpStatusCode == HttpStatus.SC_NOT_FOUND;
         }
+
+        @Override
+        public boolean isTransientError() {
+            return httpStatusCode >= 500;
+        }
     }
 
     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/push/autopush/test/TestLiveAutopushClient.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
@@ -28,17 +28,17 @@ 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.")
+// @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;