diff --git a/APNS.js b/APNS.js index 5fc73ab0f27..fe8fdcd3598 100644 --- a/APNS.js +++ b/APNS.js @@ -33,6 +33,10 @@ function APNS(args) { }); this.sender.on("socketError", console.error); + + this.sender.on("transmitted", function(notification, device) { + console.log("Notification transmitted to:" + device.token.toString("hex")); + }); } /** diff --git a/GCM.js b/GCM.js index b9d5c728d71..35ef0dfa66a 100644 --- a/GCM.js +++ b/GCM.js @@ -5,8 +5,8 @@ var randomstring = require('randomstring'); var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks var GCMRegistrationTokensMax = 1000; -function GCM(apiKey) { - this.sender = new gcm.Sender(apiKey); +function GCM(args) { + this.sender = new gcm.Sender(args.apiKey); } /** @@ -39,6 +39,10 @@ GCM.prototype.send = function (data, registrationTokens) { this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) { // TODO: Use the response from gcm to generate and save push report // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + console.log('GCM request and response %j', { + request: message, + response: response + }); promise.resolve(); }); return promise; @@ -76,6 +80,8 @@ var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } +GCM.GCMRegistrationTokensMax = GCMRegistrationTokensMax; + if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; } diff --git a/ParsePushAdapter.js b/ParsePushAdapter.js new file mode 100644 index 00000000000..5f3e94aa088 --- /dev/null +++ b/ParsePushAdapter.js @@ -0,0 +1,149 @@ +// ParsePushAdapter is the default implementation of +// PushAdapter, it uses GCM for android push and APNS +// for ios push. +var Parse = require('parse/node').Parse, + GCM = require('./GCM'), + APNS = require('./APNS'); + +function ParsePushAdapter() { + this.validPushTypes = ['ios', 'android']; + this.senders = {}; +} + +ParsePushAdapter.prototype.registerPushSenders = function(pushConfig) { + // Initialize senders + for (var i = 0; i < this.validPushTypes.length; i++) { + this.senders[this.validPushTypes[i]] = []; + } + + pushConfig = pushConfig || {}; + var pushTypes = Object.keys(pushConfig); + for (var i = 0; i < pushTypes.length; i++) { + var pushType = pushTypes[i]; + if (this.validPushTypes.indexOf(pushType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push to ' + pushTypes + ' is not supported'); + } + + var typePushConfig = pushConfig[pushType]; + var senderArgs = []; + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + if (Array.isArray(typePushConfig)) { + senderArgs = senderArgs.concat(typePushConfig); + } else if (typeof typePushConfig === 'object') { + senderArgs.push(typePushConfig); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push Configuration is invalid'); + } + for (var j = 0; j < senderArgs.length; j++) { + var senderArg = senderArgs[j]; + var sender; + switch (pushType) { + case 'ios': + sender = new APNS(senderArg); + break; + case 'android': + sender = new GCM(senderArg); + break; + } + this.senders[pushType].push(sender); + } + } +} + +/** + * Get an array of push senders based on the push type. + * @param {String} The push type + * @returns {Array|Undefined} An array of push senders + */ +ParsePushAdapter.prototype.getPushSenders = function(pushType) { + if (!this.senders[pushType]) { + console.log('No push sender for push type %s', pushType); + return []; + } + return this.senders[pushType]; +} + +/** + * Get an array of valid push types. + * @returns {Array} An array of valid push types + */ +ParsePushAdapter.prototype.getValidPushTypes = function() { + return this.validPushTypes; +} + +ParsePushAdapter.prototype.send = function(data, installations) { + var deviceTokenMap = classifyDeviceTokens(installations, this.validPushTypes); + var sendPromises = []; + for (var pushType in deviceTokenMap) { + var senders = this.getPushSenders(pushType); + // Since ios have dev/prod cert, a push type may have multiple senders + for (var i = 0; i < senders.length; i++) { + var sender = senders[i]; + var deviceTokens = deviceTokenMap[pushType]; + if (!sender || deviceTokens.length == 0) { + continue; + } + // For android, we can only have 1000 recepients per send + var chunkDeviceTokens = sliceDeviceTokens(pushType, deviceTokens, GCM.GCMRegistrationTokensMax); + for (var j = 0; j < chunkDeviceTokens.length; j++) { + sendPromises.push(sender.send(data, chunkDeviceTokens[j])); + } + } + } + return Parse.Promise.when(sendPromises); +} + +/** + * Classify the device token of installations based on its device type. + * @param {Object} installations An array of installations + * @param {Array} validPushTypes An array of valid push types(string) + * @returns {Object} A map whose key is device type and value is an array of device tokens + */ +function classifyDeviceTokens(installations, validPushTypes) { + // Init deviceTokenMap, create a empty array for each valid pushType + var deviceTokenMap = {}; + for (var i = 0; i < validPushTypes.length; i++) { + deviceTokenMap[validPushTypes[i]] = []; + } + for (var i = 0; i < installations.length; i++) { + var installation = installations[i]; + // No deviceToken, ignore + if (!installation.deviceToken) { + continue; + } + var pushType = installation.deviceType; + if (deviceTokenMap[pushType]) { + deviceTokenMap[pushType].push(installation.deviceToken); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceTokenMap; +} + +/** + * Slice a list of device tokens to several list of device tokens with fixed chunk size. + * @param {String} pushType The push type of the given device tokens + * @param {Array} deviceTokens An array of device tokens(string) + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of device tokens with fixed chunk size + */ +function sliceDeviceTokens(pushType, deviceTokens, chunkSize) { + if (pushType !== 'android') { + return [deviceTokens]; + } + var chunkDeviceTokens = []; + while (deviceTokens.length > 0) { + chunkDeviceTokens.push(deviceTokens.splice(0, chunkSize)); + } + return chunkDeviceTokens; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + ParsePushAdapter.classifyDeviceTokens = classifyDeviceTokens; + ParsePushAdapter.sliceDeviceTokens = sliceDeviceTokens; +} +module.exports = ParsePushAdapter; \ No newline at end of file diff --git a/PushAdapter.js b/PushAdapter.js new file mode 100644 index 00000000000..51a1408aa02 --- /dev/null +++ b/PushAdapter.js @@ -0,0 +1,29 @@ +// Push Adapter +// +// Allows you to change the push notification mechanism. +// +// Adapter classes must implement the following functions: +// * registerPushSenders(parseConfig) +// * getPushSenders(parseConfig) +// * getValidPushTypes(parseConfig) +// * send(data, installations) +// +// Default is ParsePushAdapter, which uses GCM for +// android push and APNS for ios push. + +var ParsePushAdapter = require('./ParsePushAdapter'); + +var adapter = new ParsePushAdapter(); + +function setAdapter(pushAdapter) { + adapter = pushAdapter; +} + +function getAdapter() { + return adapter; +} + +module.exports = { + getAdapter: getAdapter, + setAdapter: setAdapter +}; diff --git a/index.js b/index.js index 37a88b893ac..c29f49855df 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ var batch = require('./batch'), express = require('express'), FilesAdapter = require('./FilesAdapter'), S3Adapter = require('./S3Adapter'), + PushAdapter = require('./PushAdapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, @@ -80,6 +81,10 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + // Register push senders + var pushConfig = args.push; + PushAdapter.getAdapter().registerPushSenders(pushConfig); + // Initialize the node client SDK automatically Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); if(args.serverURL) { diff --git a/push.js b/push.js index 29a6a944e57..c66bc1aec00 100644 --- a/push.js +++ b/push.js @@ -2,27 +2,34 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), + PushAdapter = require('./PushAdapter'), rest = require('./rest'); -var validPushTypes = ['ios', 'android']; - function handlePushWithoutQueue(req) { validateMasterKey(req); var where = getQueryCondition(req); - validateDeviceType(where); + var pushAdapter = PushAdapter.getAdapter(); + validatePushType(where, pushAdapter.getValidPushTypes()); // Replace the expiration_time with a valid Unix epoch milliseconds time req.body['expiration_time'] = getExpirationTime(req); - return rest.find(req.config, req.auth, '_Installation', where).then(function(response) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); + // TODO: If the req can pass the checking, we return immediately instead of waiting + // pushes to be sent. We probably change this behaviour in the future. + rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + return pushAdapter.send(req.body, response.results); + }); + return Parse.Promise.as({ + response: { + 'result': true + } }); } /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition + * @param {Array} validPushTypes An array of valid push types(string) */ -function validateDeviceType(where) { +function validatePushType(where, validPushTypes) { var where = where || {}; var deviceTypeField = where.deviceType || {}; var deviceTypes = []; @@ -113,12 +120,12 @@ var router = new PromiseRouter(); router.route('POST','/push', handlePushWithoutQueue); module.exports = { - router: router + router: router, } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { module.exports.getQueryCondition = getQueryCondition; module.exports.validateMasterKey = validateMasterKey; module.exports.getExpirationTime = getExpirationTime; - module.exports.validateDeviceType = validateDeviceType; + module.exports.validatePushType = validatePushType; } diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js new file mode 100644 index 00000000000..58ad24acbef --- /dev/null +++ b/spec/ParsePushAdapter.spec.js @@ -0,0 +1,222 @@ +var ParsePushAdapter = require('../ParsePushAdapter'); + +describe('ParsePushAdapter', () => { + it('can be initialized', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.validPushTypes).toEqual(['ios', 'android']); + done(); + }); + + it('can register push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: { + senderId: 'senderId', + apiKey: 'apiKey' + }, + ios: [ + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true + }, + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false + } + ] + }; + + parsePushAdapter.registerPushSenders(pushConfig); + // Check ios + var iosSenders = parsePushAdapter.senders['ios']; + expect(iosSenders.length).toBe(2); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = iosSenders[0].sender.options; + expect(prodApnsOptions.cert).toBe(pushConfig.ios[0].cert); + expect(prodApnsOptions.key).toBe(pushConfig.ios[0].key); + expect(prodApnsOptions.production).toBe(pushConfig.ios[0].production); + var devApnsOptions = iosSenders[1].sender.options; + expect(devApnsOptions.cert).toBe(pushConfig.ios[1].cert); + expect(devApnsOptions.key).toBe(pushConfig.ios[1].key); + expect(devApnsOptions.production).toBe(pushConfig.ios[1].production); + // Check android + var androidSenders = parsePushAdapter.senders['android']; + expect(androidSenders.length).toBe(1); + var androidSender = androidSenders[0]; + // TODO: Remove this checking onec we inject GCM + expect(androidSender.sender.key).toBe(pushConfig.android.apiKey); + done(); + }); + + it('can throw on registering with unsupported push type', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + win: { + senderId: 'senderId', + apiKey: 'apiKey' + } + }; + + expect(function() { + parsePushAdapter.registerPushSenders(pushConfig) + }).toThrow(); + done(); + }); + + it('can throw on registering with invalid pushConfig', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: 123 + }; + + expect(function() { + parsePushAdapter.registerPushSenders(pushConfig) + }).toThrow(); + done(); + }); + + it('can get push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock push senders + var androidSender = {}; + var iosSender = {}; + var iosSenderAgain = {}; + parsePushAdapter.senders = { + android: [ + androidSender + ], + ios: [ + iosSender, + iosSenderAgain + ] + }; + + expect(parsePushAdapter.getPushSenders('android')).toEqual([androidSender]); + expect(parsePushAdapter.getPushSenders('ios')).toEqual([iosSender, iosSenderAgain]); + done(); + }); + + it('can get empty push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getPushSenders('android')).toEqual([]); + done(); + }); + + it('can get valid push types', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can get classify device tokens', (done) => { + // Mock installations + var validPushTypes = ['ios', 'android']; + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + var deviceTokenMap = ParsePushAdapter.classifyDeviceTokens(installations, validPushTypes); + expect(deviceTokenMap['android']).toEqual(['androidToken']); + expect(deviceTokenMap['ios']).toEqual(['iosToken']); + expect(deviceTokenMap['win']).toBe(undefined); + done(); + }); + + it('can slice ios device tokens', (done) => { + // Mock installations + var deviceTokens = [1, 2, 3, 4]; + + var chunkDeviceTokens = ParsePushAdapter.sliceDeviceTokens('ios', deviceTokens, 2); + expect(chunkDeviceTokens).toEqual([[1, 2, 3, 4]]); + done(); + }); + + it('can slice android device tokens', (done) => { + // Mock installations + var deviceTokens = [1, 2, 3, 4]; + + var chunkDeviceTokens = ParsePushAdapter.sliceDeviceTokens('android', deviceTokens, 3); + expect(chunkDeviceTokens).toEqual([[1, 2, 3], [4]]); + done(); + }); + + + it('can send push notifications', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock android ios senders + var androidSender = { + send: jasmine.createSpy('send') + }; + var iosSender = { + send: jasmine.createSpy('send') + }; + var iosSenderAgain = { + send: jasmine.createSpy('send') + }; + var senders = { + ios: [iosSender, iosSenderAgain], + android: [androidSender] + }; + parsePushAdapter.senders = senders; + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + parsePushAdapter.send(data, installations); + // Check android sender + expect(androidSender.send).toHaveBeenCalled(); + var args = androidSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual(['androidToken']); + // Check ios sender + expect(iosSender.send).toHaveBeenCalled(); + args = iosSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual(['iosToken']); + expect(iosSenderAgain.send).toHaveBeenCalled(); + args = iosSenderAgain.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual(['iosToken']); + done(); + }); +}); diff --git a/spec/push.spec.js b/spec/push.spec.js index ba5b533bbee..30955363ead 100644 --- a/spec/push.spec.js +++ b/spec/push.spec.js @@ -104,10 +104,11 @@ describe('push', () => { it('can validate device type when no device type is set', (done) => { // Make query condition var where = { - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -116,10 +117,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'ios' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -130,10 +132,11 @@ describe('push', () => { 'deviceType': { '$in': ['android', 'ios'] } - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -142,10 +145,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -154,10 +158,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where) + push.validatePushType(where, validPushTypes); }).toThrow(); done(); });