Bug 1214338 - Implement Android GCM-based PushService protocol. r=rnewman r?kitcambridge draft
authorNick Alexander <nalexander@mozilla.com>
Wed, 02 Mar 2016 17:17:27 -0800
changeset 336362 9ed17d2d12bb02fc1e104037d18d2e7bc0e2738c
parent 336361 405d46117555755acc6227a9ae1b5702000a7951
child 515369 5b3ee112a8e3666752972a3688f858ff11c6ae51
push id12037
push usernalexander@mozilla.com
push dateThu, 03 Mar 2016 01:19:17 +0000
reviewersrnewman, kitcambridge
bugs1214338
milestone47.0a1
Bug 1214338 - Implement Android GCM-based PushService protocol. r=rnewman r?kitcambridge MozReview-Commit-ID: 1KV7CZBuosx
dom/push/PushService.jsm
dom/push/PushServiceAndroidGCM.jsm
dom/push/moz.build
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/push/PushService.java
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -5,29 +5,36 @@
 
 "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];
+const CONNECTION_PROTOCOLS = (function() {
+  if ('android' != AppConstants.MOZ_WIDGET_TOOLKIT) {
+    const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm");
+    const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm");
+    return [PushServiceWebSocket, PushServiceHttp2];
+  } else {
+    const {PushServiceAndroidGCM} = Cu.import("resource://gre/modules/PushServiceAndroidGCM.jsm");
+    return [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") {
+      // TODO: Use Messaging.jsm for this.
+      if (this._mainPushService == null) {
+        // Shouldn't ever happen, but let's be careful.
+        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;
+    },
+  },
+});
+
+// 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/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -170,16 +170,17 @@ public class GeckoApplication extends Ap
             // 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.registerGeckoEventListener();
 
                     try {
                         PushService.getInstance().onStartup();
                     } catch (Exception e) {
                         Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
                         return;
                     }
                 }
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -29,23 +29,33 @@ import java.util.Map;
 /**
  * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
  * <p/>
  * This singleton 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 {
+public class PushService implements BundleEventListener {
     private static final String LOG_TAG = "GeckoPushService";
 
     public static final String SERVICE_WEBPUSH = "webpush";
 
     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) {
@@ -82,17 +92,17 @@ public class PushService {
     public void onRefresh() {
         Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again.");
         ThreadUtils.assertOnBackgroundThread();
 
         pushManager.invalidateGcmToken();
         try {
             pushManager.startup(System.currentTimeMillis());
         } catch (Exception e) {
-            Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+            Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e);
             return;
         }
     }
 
     public void onMessageReceived(final @NonNull Bundle bundle) {
         Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
         ThreadUtils.assertOnBackgroundThread();
 
@@ -114,16 +124,171 @@ public class PushService {
             // 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_WEBPUSH.equals(subscription.service)) {
-            // Nothing yet.
-            Log.i(LOG_TAG, "Message directed to unimplemented service; ignoring: " + subscription.service);
-            return;
+            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()));
         } else {
             Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service);
         }
     }
+
+    public static void registerGeckoEventListener() {
+        Log.d(LOG_TAG, "Registered Gecko event listener.");
+        EventDispatcher.getInstance().registerBackgroundThreadListener(getInstance(), GECKO_EVENTS);
+    }
+
+    public static void unregisterGeckoEventListener() {
+        Log.d(LOG_TAG, "Unregistered Gecko event 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());
+
+        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;
+            }
+            if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
+                callback.sendError("Not yet implemented!");
+                return;
+            }
+            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);
+                } catch (JSONException e) {
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                }
+                return;
+            }
+            if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
+                try {
+                    final PushRegistration registration = pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis());
+                    callback.sendSuccess(null);
+                } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+                    Log.e(LOG_TAG, "Got exception in " + event, e);
+                    callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+                }
+                return;
+            }
+            if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
+                callback.sendError("Not yet implemented!");
+                return;
+            }
+            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) {
+                    Log.e(LOG_TAG, "Got exception in " + event, 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) {
+                    Log.e(LOG_TAG, "Got exception in " + event, 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);
+                return;
+            }
+            if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
+                callback.sendError("Not yet implemented!");
+                return;
+            }
+        } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+            // 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.
+            callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
+        }
+    }
 }