Bug 1214338 - Implement Android GCM-based PushService protocol. r=rnewman r?kitcambridge
MozReview-Commit-ID: 1KV7CZBuosx
--- 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.");
+ }
+ }
}