Bug 1214338 - Implement Android GCM-based PushService protocol. r=rnewman r=kitcambridge

MozReview-Commit-ID: 1KV7CZBuosx
This commit is contained in:
Nick Alexander 2016-03-04 15:48:09 -08:00
parent 6a26062006
commit 99ade81c39
5 changed files with 456 additions and 15 deletions

View File

@ -10,19 +10,26 @@ 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");

View File

@ -0,0 +1,255 @@
/* 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,
getCryptoParams,
} = 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 || data.cryptokey)) {
let headers = {
encryption_key: data.enckey,
crypto_key: data.cryptokey,
encryption: data.enc,
};
cryptoParams = getCryptoParams(headers);
// 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.keyID)) {
console.debug("connect:", "hasOwnProperty", record.keyID);
return Promise.resolve();
}
console.debug("connect:", "!hasOwnProperty", record.keyID);
// 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,
ctime: ctime,
// Cryptography!
p256dhPublicKey: exportedKeys[0],
p256dhPrivateKey: exportedKeys[1],
authenticationSecret: PushCrypto.generateAuthenticationSecret(),
})
);
});
},
_unregister: function(record) {
return Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:UnsubscribeChannel",
channelID: record.keyID,
});
},
};
function PushRecordAndroidGCM(record) {
PushRecord.call(this, record);
this.channelID = record.channelID;
}
PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
keyID: {
get() {
return this.channelID;
},
},
});

View File

@ -14,10 +14,20 @@ EXTRA_JS_MODULES += [
'PushDB.jsm',
'PushRecord.jsm',
'PushService.jsm',
'PushServiceHttp2.jsm',
'PushServiceWebSocket.jsm',
]
if CONFIG['MOZ_BUILD_APP'] != 'mobile/android':
# Everything but Fennec.
EXTRA_JS_MODULES += [
'PushServiceHttp2.jsm',
'PushServiceWebSocket.jsm',
]
else:
# Fennec only.
EXTRA_JS_MODULES += [
'PushServiceAndroidGCM.jsm',
]
MOCHITEST_MANIFESTS += [
'test/mochitest.ini',
]

View File

@ -175,6 +175,7 @@ public class GeckoApplication extends Application
// 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();

View File

@ -34,13 +34,23 @@ import java.util.Map;
* <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!");
@ -87,7 +97,7 @@ public class PushService {
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;
}
}
@ -119,11 +129,169 @@ public class PushService {
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"));
// Only one of cryptokey (newer) and enckey (deprecated) should be set, but the
// Gecko handler will verify this.
data.put("cryptokey", bundle.getString("cryptokey"));
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 {
pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects.
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.");
}
}
}