diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 72490e9791..c56e35d550 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,6 +1,65 @@ var APNS = require('../src/APNS'); describe('APNS', () => { + + it('can initialize with single cert', (done) => { + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + + expect(apns.conns.length).toBe(1); + var apnsConnection = apns.conns[0]; + expect(apnsConnection.index).toBe(0); + expect(apnsConnection.bundleId).toBe(args.bundleId); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = apnsConnection.options; + expect(prodApnsOptions.cert).toBe(args.cert); + expect(prodApnsOptions.key).toBe(args.key); + expect(prodApnsOptions.production).toBe(args.production); + done(); + }); + + it('can initialize with multiple certs', (done) => { + var args = [ + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false, + bundleId: 'bundleId' + }, + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleIdAgain' + } + ] + + var apns = new APNS(args); + expect(apns.conns.length).toBe(2); + var devApnsConnection = apns.conns[1]; + expect(devApnsConnection.index).toBe(1); + var devApnsOptions = devApnsConnection.options; + expect(devApnsOptions.cert).toBe(args[0].cert); + expect(devApnsOptions.key).toBe(args[0].key); + expect(devApnsOptions.production).toBe(args[0].production); + expect(devApnsConnection.bundleId).toBe(args[0].bundleId); + + var prodApnsConnection = apns.conns[0]; + expect(prodApnsConnection.index).toBe(0); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = prodApnsConnection.options; + expect(prodApnsOptions.cert).toBe(args[1].cert); + expect(prodApnsOptions.key).toBe(args[1].key); + expect(prodApnsOptions.production).toBe(args[1].production); + expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); + done(); + }); + it('can generate APNS notification', (done) => { //Mock request data var data = { @@ -29,12 +88,195 @@ describe('APNS', () => { done(); }); + it('can choose conns for device without appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = {}; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0, 1]); + done(); + }); + + it('can choose conns for device with valid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'bundleId' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0]); + done(); + }); + + it('can choose conns for device with invalid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'invalid' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([]); + done(); + }); + + it('can handle transmission error when notification is not in cache or device is missing', (done) => { + // Mock conns + var conns = []; + var errorCode = 1; + var notification = undefined; + var device = {}; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + + var notification = {}; + var device = undefined; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + done(); + }); + + it('can handle transmission error when there are other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 0, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when there is no other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + } + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 2, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + expect(conns[3].pushNotification).not.toHaveBeenCalled(); + expect(conns[4].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when device has no appIdentifier', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId3' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 1, + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).toHaveBeenCalled(); + done(); + }); + it('can send APNS notification', (done) => { - var apns = new APNS(); - var sender = { - pushNotification: jasmine.createSpy('send') + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + var conn = { + pushNotification: jasmine.createSpy('send'), + bundleId: 'bundleId' }; - apns.sender = sender; + apns.conns = [ conn ]; // Mock data var expirationTime = 1454571491354 var data = { @@ -43,16 +285,23 @@ describe('APNS', () => { 'alert': 'alert' } } - // Mock registrationTokens - var deviceTokens = ['token']; + // Mock devices + var devices = [ + { + deviceToken: '112233', + appIdentifier: 'bundleId' + } + ]; - var promise = apns.send(data, deviceTokens); - expect(sender.pushNotification).toHaveBeenCalled(); - var args = sender.pushNotification.calls.first().args; + var promise = apns.send(data, devices); + expect(conn.pushNotification).toHaveBeenCalled(); + var args = conn.pushNotification.calls.first().args; var notification = args[0]; expect(notification.alert).toEqual(data.data.alert); expect(notification.expiry).toEqual(data['expiration_time']); - expect(args[1]).toEqual(deviceTokens); + var apnDevice = args[1] + expect(apnDevice.connIndex).toEqual(0); + expect(apnDevice.appIdentifier).toEqual('bundleId'); done(); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 4bad883ee4..30f1a99788 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,6 +1,23 @@ var GCM = require('../src/GCM'); describe('GCM', () => { + it('can initialize', (done) => { + var args = { + apiKey: 'apiKey' + }; + var gcm = new GCM(args); + expect(gcm.sender.key).toBe(args.apiKey); + done(); + }); + + it('can throw on initializing with invalid args', (done) => { + var args = 123 + expect(function() { + new GCM(args); + }).toThrow(); + done(); + }); + it('can generate GCM Payload without expiration time', (done) => { //Mock request data var data = { @@ -90,7 +107,9 @@ describe('GCM', () => { }); it('can send GCM request', (done) => { - var gcm = new GCM('apiKey'); + var gcm = new GCM({ + apiKey: 'apiKey' + }); // Mock gcm sender var sender = { send: jasmine.createSpy('send') @@ -104,34 +123,37 @@ describe('GCM', () => { 'alert': 'alert' } } - // Mock registrationTokens - var registrationTokens = ['token']; + // Mock devices + var devices = [ + { + deviceToken: 'token' + } + ]; - var promise = gcm.send(data, registrationTokens); + gcm.send(data, devices); expect(sender.send).toHaveBeenCalled(); var args = sender.send.calls.first().args; // It is too hard to verify message of gcm library, we just verify tokens and retry times - expect(args[1].registrationTokens).toEqual(registrationTokens); + expect(args[1].registrationTokens).toEqual(['token']); expect(args[2]).toEqual(5); done(); }); - it('can throw on sending when we have too many registration tokens', (done) => { - var gcm = new GCM('apiKey'); - // Mock gcm sender - var sender = { - send: jasmine.createSpy('send') - }; - gcm.sender = sender; - // Mock registrationTokens - var registrationTokens = []; - for (var i = 0; i <= 2000; i++) { - registrationTokens.push(i.toString()); - } + it('can slice devices', (done) => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - expect(function() { - gcm.send({}, registrationTokens); - }).toThrow(); + var chunkDevices = GCM.sliceDevices(devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)] + ]); done(); }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken + }; + } }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js new file mode 100644 index 0000000000..4cc4f0f85f --- /dev/null +++ b/spec/ParsePushAdapter.spec.js @@ -0,0 +1,150 @@ +var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); +var APNS = require('../src/APNS'); +var GCM = require('../src/GCM'); + +describe('ParsePushAdapter', () => { + it('can be initialized', (done) => { + // Make mock config + var pushConfig = { + android: { + senderId: 'senderId', + apiKey: 'apiKey' + }, + ios: [ + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + }, + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false, + bundleId: 'bundleIdAgain' + } + ] + }; + + var parsePushAdapter = new ParsePushAdapter(pushConfig); + // Check ios + var iosSender = parsePushAdapter.senderMap['ios']; + expect(iosSender instanceof APNS).toBe(true); + // Check android + var androidSender = parsePushAdapter.senderMap['android']; + expect(androidSender instanceof GCM).toBe(true); + done(); + }); + + it('can throw on initializing with unsupported push type', (done) => { + // Make mock config + var pushConfig = { + win: { + senderId: 'senderId', + apiKey: 'apiKey' + } + }; + + expect(function() { + new ParsePushAdapter(pushConfig); + }).toThrow(); + done(); + }); + + it('can get valid push types', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can classify installation', (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 deviceMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceMap['win']).toBe(undefined); + 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 senderMap = { + ios: iosSender, + android: androidSender + }; + parsePushAdapter.senderMap = senderMap; + // 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([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender.send).toHaveBeenCalled(); + args = iosSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + function makeDevice(deviceToken, appIdentifier) { + return { + deviceToken: deviceToken, + appIdentifier: appIdentifier + }; + } +}); diff --git a/spec/push.spec.js b/spec/PushController.spec.js similarity index 75% rename from spec/push.spec.js rename to spec/PushController.spec.js index a2ea41b5e6..5414eca2bd 100644 --- a/spec/push.spec.js +++ b/spec/PushController.spec.js @@ -1,6 +1,6 @@ -var push = require('../src/push'); +var PushController = require('../src/Controllers/PushController').PushController; -describe('push', () => { +describe('PushController', () => { it('can check valid master key of request', (done) => { // Make mock request var request = { @@ -13,7 +13,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).not.toThrow(); done(); }); @@ -30,7 +30,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).toThrow(); done(); }); @@ -43,7 +43,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'channels': { '$in': ['Giants', 'Mets'] @@ -62,7 +62,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'injuryReports': true }); @@ -77,7 +77,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -96,7 +96,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -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); + PushController.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); + PushController.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); + PushController.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); + PushController.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) + PushController.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -171,7 +176,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(new Date(timeStr).valueOf()); done(); }); @@ -185,7 +190,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(timeNumber * 1000); done(); }); @@ -199,7 +204,7 @@ describe('push', () => { } expect(function(){ - push.getExpirationTime(request); + PushController.getExpirationTime(request); }).toThrow(); done(); }); diff --git a/src/APNS.js b/src/APNS.js index 85c97401a5..500be9e23f 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,66 +1,177 @@ -var Parse = require('parse/node').Parse; +"use strict"; + +const Parse = require('parse/node').Parse; // TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, // but probably we will replace it in the future. -var apn = require('apn'); +const apn = require('apn'); /** * Create a new connection to the APN service. * @constructor - * @param {Object} args Arguments to config APNS connection - * @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem - * @param {String} args.key The filename of the connection key to load from disk, default is key.pem + * @param {Object|Array} args An argument or a list of arguments to config APNS connection + * @param {String} args.cert The filename of the connection certificate to load from disk + * @param {String} args.key The filename of the connection key to load from disk + * @param {String} args.pfx The filename for private key, certificate and CA certs in PFX or PKCS12 format, it will overwrite cert and key * @param {String} args.passphrase The passphrase for the connection key, if required + * @param {String} args.bundleId The bundleId for cert * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox */ function APNS(args) { - this.sender = new apn.connection(args); + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + let apnsArgsList = []; + if (Array.isArray(args)) { + apnsArgsList = apnsArgsList.concat(args); + } else if (typeof args === 'object') { + apnsArgsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'APNS Configuration is invalid'); + } - this.sender.on('connected', function() { - console.log('APNS Connected'); - }); + this.conns = []; + for (let apnsArgs of apnsArgsList) { + let conn = new apn.Connection(apnsArgs); + if (!apnsArgs.bundleId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'BundleId is mssing for %j', apnsArgs); + } + conn.bundleId = apnsArgs.bundleId; + // Set the priority of the conns, prod cert has higher priority + if (apnsArgs.production) { + conn.priority = 0; + } else { + conn.priority = 1; + } - this.sender.on('transmissionError', function(errCode, notification, device) { - console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification); - // TODO: For error caseud by invalid deviceToken, we should mark those installations. - }); + // Set apns client callbacks + conn.on('connected', () => { + console.log('APNS Connection %d Connected', conn.index); + }); - this.sender.on("timeout", function () { - console.log("APNS Connection Timeout"); - }); + conn.on('transmissionError', (errCode, notification, apnDevice) => { + handleTransmissionError(this.conns, errCode, notification, apnDevice); + }); - this.sender.on("disconnected", function() { - console.log("APNS Disconnected"); - }); + conn.on('timeout', () => { + console.log('APNS Connection %d Timeout', conn.index); + }); - this.sender.on("socketError", console.error); + conn.on('disconnected', () => { + console.log('APNS Connection %d Disconnected', conn.index); + }); + + conn.on('socketError', () => { + console.log('APNS Connection %d Socket Error', conn.index); + }); + + conn.on('transmitted', function(notification, device) { + console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); + }); + + this.conns.push(conn); + } + // Sort the conn based on priority ascending, high pri first + this.conns.sort((s1, s2) => { + return s1.priority - s2.priority; + }); + // Set index of conns + for (let index = 0; index < this.conns.length; index++) { + this.conns[index].index = index; + } } /** * Send apns request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} deviceTokens A array of device tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved immediately */ -APNS.prototype.send = function(data, deviceTokens) { - var coreData = data.data; - var expirationTime = data['expiration_time']; - var notification = generateNotification(coreData, expirationTime); - this.sender.pushNotification(notification, deviceTokens); - // TODO: pushNotification will push the notification to apn's queue. - // We do not handle error in V1, we just relies apn to auto retry and send the - // notifications. +APNS.prototype.send = function(data, devices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData, expirationTime); + for (let device of devices) { + let qualifiedConnIndexs = chooseConns(this.conns, device); + // We can not find a valid conn, just ignore this device + if (qualifiedConnIndexs.length == 0) { + continue; + } + let conn = this.conns[qualifiedConnIndexs[0]]; + let apnDevice = new apn.Device(device.deviceToken); + apnDevice.connIndex = qualifiedConnIndexs[0]; + // Add additional appIdentifier info to apn device instance + if (device.appIdentifier) { + apnDevice.appIdentifier = device.appIdentifier; + } + conn.pushNotification(notification, apnDevice); + } return Parse.Promise.as(); } +function handleTransmissionError(conns, errCode, notification, apnDevice) { + console.error('APNS Notification caused error: ' + errCode + ' for device ', apnDevice, notification); + // This means the error notification is not in the cache anymore or the recepient is missing, + // we just ignore this case + if (!notification || !apnDevice) { + return + } + + // If currentConn can not send the push notification, we try to use the next available conn. + // Since conns is sorted by priority, the next conn means the next low pri conn. + // If there is no conn available, we give up on sending the notification to that device. + let qualifiedConnIndexs = chooseConns(conns, apnDevice); + let currentConnIndex = apnDevice.connIndex; + + let newConnIndex = -1; + // Find the next element of currentConnIndex in qualifiedConnIndexs + for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { + if (qualifiedConnIndexs[index] === currentConnIndex) { + newConnIndex = qualifiedConnIndexs[index + 1]; + break; + } + } + // There is no more available conns, we give up in this case + if (newConnIndex < 0 || newConnIndex >= conns.length) { + console.log('APNS can not find vaild connection for %j', apnDevice.token); + return; + } + + let newConn = conns[newConnIndex]; + // Update device conn info + apnDevice.connIndex = newConnIndex; + // Use the new conn to send the notification + newConn.pushNotification(notification, apnDevice); +} + +function chooseConns(conns, device) { + // If device does not have appIdentifier, all conns maybe proper connections. + // Otherwise we try to match the appIdentifier with bundleId + let qualifiedConns = []; + for (let index = 0; index < conns.length; index++) { + let conn = conns[index]; + // If the device we need to send to does not have + // appIdentifier, any conn could be a qualified connection + if (!device.appIdentifier || device.appIdentifier === '') { + qualifiedConns.push(index); + continue; + } + if (device.appIdentifier === conn.bundleId) { + qualifiedConns.push(index); + } + } + return qualifiedConns; +} + /** * Generate the apns notification from the data we get from api request. * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -var generateNotification = function(coreData, expirationTime) { - var notification = new apn.notification(); - var payload = {}; - for (var key in coreData) { +function generateNotification(coreData, expirationTime) { + let notification = new apn.notification(); + let payload = {}; + for (let key in coreData) { switch (key) { case 'alert': notification.setAlertText(coreData.alert); @@ -73,7 +184,7 @@ var generateNotification = function(coreData, expirationTime) { break; case 'content-available': notification.setNewsstandAvailable(true); - var isAvailable = coreData['content-available'] === 1; + let isAvailable = coreData['content-available'] === 1; notification.setContentAvailable(isAvailable); break; case 'category': @@ -91,5 +202,7 @@ var generateNotification = function(coreData, expirationTime) { if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; + APNS.chooseConns = chooseConns; + APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js new file mode 100644 index 0000000000..1ae1647f92 --- /dev/null +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -0,0 +1,88 @@ +"use strict"; +// ParsePushAdapter is the default implementation of +// PushAdapter, it uses GCM for android push and APNS +// for ios push. + +const Parse = require('parse/node').Parse; +const GCM = require('../../GCM'); +const APNS = require('../../APNS'); + +function ParsePushAdapter(pushConfig) { + this.validPushTypes = ['ios', 'android']; + this.senderMap = {}; + + pushConfig = pushConfig || {}; + let pushTypes = Object.keys(pushConfig); + for (let pushType of pushTypes) { + if (this.validPushTypes.indexOf(pushType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push to ' + pushTypes + ' is not supported'); + } + switch (pushType) { + case 'ios': + this.senderMap[pushType] = new APNS(pushConfig[pushType]); + break; + case 'android': + this.senderMap[pushType] = new GCM(pushConfig[pushType]); + break; + } + } +} + +/** + * 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) { + let deviceMap = classifyInstallation(installations, this.validPushTypes); + let sendPromises = []; + for (let pushType in deviceMap) { + let sender = this.senderMap[pushType]; + if (!sender) { + console.log('Can not find sender for push type %s, %j', pushType, data); + continue; + } + let devices = deviceMap[pushType]; + sendPromises.push(sender.send(data, devices)); + } + return Parse.Promise.when(sendPromises); +} + +/**g + * 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 + */ +function classifyInstallation(installations, validPushTypes) { + // Init deviceTokenMap, create a empty array for each valid pushType + let deviceMap = {}; + for (let validPushType of validPushTypes) { + deviceMap[validPushType] = []; + } + for (let installation of installations) { + // No deviceToken, ignore + if (!installation.deviceToken) { + continue; + } + let pushType = installation.deviceType; + if (deviceMap[pushType]) { + deviceMap[pushType].push({ + deviceToken: installation.deviceToken, + appIdentifier: installation.appIdentifier + }); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceMap; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + ParsePushAdapter.classifyInstallation = classifyInstallation; +} +module.exports = ParsePushAdapter; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js new file mode 100644 index 0000000000..1e07467fa0 --- /dev/null +++ b/src/Adapters/Push/PushAdapter.js @@ -0,0 +1,17 @@ +// Push Adapter +// +// Allows you to change the push notification mechanism. +// +// Adapter classes must implement the following functions: +// * getValidPushTypes() +// * send(devices, installations) +// +// Default is ParsePushAdapter, which uses GCM for +// android push and APNS for ios push. +export class PushAdapter { + send(devices, installations) { } + + getValidPushTypes() { } +} + +export default PushAdapter; diff --git a/src/push.js b/src/Controllers/PushController.js similarity index 65% rename from src/push.js rename to src/Controllers/PushController.js index 29a6a944e5..9f9252dcc8 100644 --- a/src/push.js +++ b/src/Controllers/PushController.js @@ -1,28 +1,52 @@ -// push.js +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); +export class PushController { -var validPushTypes = ['ios', 'android']; + constructor(pushAdapter) { + this._pushAdapter = pushAdapter; + } + + handlePOST(req) { + if (!this._pushAdapter) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push adapter is not availabe'); + } + + validateMasterKey(req); + var where = getQueryCondition(req); + var pushAdapter = this._pushAdapter; + validatePushType(where, pushAdapter.getValidPushTypes()); + // Replace the expiration_time with a valid Unix epoch milliseconds time + req.body['expiration_time'] = getExpirationTime(req); + // 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 + } + }); + } -function handlePushWithoutQueue(req) { - validateMasterKey(req); - var where = getQueryCondition(req); - validateDeviceType(where); - // 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.'); - }); + getExpressRouter() { + var router = new PromiseRouter(); + router.route('POST','/push', (req) => { + return this.handlePOST(req); + }); + return router; + } } /** * 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 = []; @@ -109,16 +133,11 @@ function validateMasterKey(req) { } } -var router = new PromiseRouter(); -router.route('POST','/push', handlePushWithoutQueue); - -module.exports = { - 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; + PushController.getQueryCondition = getQueryCondition; + PushController.validateMasterKey = validateMasterKey; + PushController.getExpirationTime = getExpirationTime; + PushController.validatePushType = validatePushType; } + +export default PushController; diff --git a/src/GCM.js b/src/GCM.js index b9d5c728d7..be09f222d5 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -1,47 +1,66 @@ -var Parse = require('parse/node').Parse; -var gcm = require('node-gcm'); -var randomstring = require('randomstring'); +"use strict"; -var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks -var GCMRegistrationTokensMax = 1000; +const Parse = require('parse/node').Parse; +const gcm = require('node-gcm'); +const randomstring = require('randomstring'); -function GCM(apiKey) { - this.sender = new gcm.Sender(apiKey); +const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks +const GCMRegistrationTokensMax = 1000; + +function GCM(args) { + if (typeof args !== 'object' || !args.apiKey) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'GCM Configuration is invalid'); + } + this.sender = new gcm.Sender(args.apiKey); } /** * Send gcm request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} registrationTokens A array of registration tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved after we get results from gcm */ -GCM.prototype.send = function (data, registrationTokens) { - if (registrationTokens.length >= GCMRegistrationTokensMax) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Too many registration tokens for a GCM request.'); - } - var pushId = randomstring.generate({ +GCM.prototype.send = function(data, devices) { + let pushId = randomstring.generate({ length: 10, charset: 'alphanumeric' }); - var timeStamp = Date.now(); - var expirationTime; + let timeStamp = Date.now(); + let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date // in Unix epoch time in milliseconds here if (data['expiration_time']) { expirationTime = data['expiration_time']; } // Generate gcm payload - var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); // Make and send gcm request - var message = new gcm.Message(gcmPayload); - var promise = new Parse.Promise(); - 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 - promise.resolve(); - }); - return promise; + let message = new gcm.Message(gcmPayload); + + let sendPromises = []; + // For android, we can only have 1000 recepients per send, so we need to slice devices to + // chunk if necessary + let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax); + for (let chunkDevice of chunkDevices) { + let sendPromise = new Parse.Promise(); + let registrationTokens = [] + for (let device of chunkDevice) { + registrationTokens.push(device.deviceToken); + } + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (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 + }); + sendPromise.resolve(); + }); + sendPromises.push(sendPromise); + } + + return Parse.Promise.when(sendPromises); } /** @@ -52,19 +71,19 @@ GCM.prototype.send = function (data, registrationTokens) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { - var payloadData = { +function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) { + let payloadData = { 'time': new Date(timeStamp).toISOString(), 'push_id': pushId, 'data': JSON.stringify(coreData) } - var payload = { + let payload = { priority: 'normal', data: payloadData }; if (expirationTime) { // The timeStamp and expiration is in milliseconds but gcm requires second - var timeToLive = Math.floor((expirationTime - timeStamp) / 1000); + let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); if (timeToLive < 0) { timeToLive = 0; } @@ -76,7 +95,22 @@ var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } +/** + * Slice a list of devices to several list of devices with fixed chunk size. + * @param {Array} devices An array of devices + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of devices with fixed chunk size + */ +function sliceDevices(devices, chunkSize) { + let chunkDevices = []; + while (devices.length > 0) { + chunkDevices.push(devices.splice(0, chunkSize)); + } + return chunkDevices; +} + if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; + GCM.sliceDevices = sliceDevices; } module.exports = GCM; diff --git a/src/index.js b/src/index.js index ef29ec7f39..16c1509806 100644 --- a/src/index.js +++ b/src/index.js @@ -13,9 +13,12 @@ var batch = require('./batch'), import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; - import { FilesController } from './Controllers/FilesController'; +import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; +import { PushController } from './Controllers/PushController'; + + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -41,6 +44,8 @@ addParseCloud(); // "dotNetKey": optional key from Parse dashboard // "restAPIKey": optional key from Parse dashboard // "javascriptKey": optional key from Parse dashboard +// "push": optional key from configure push + function ParseServer(args) { if (!args.appId || !args.masterKey) { throw 'You must provide an appId and masterKey!'; @@ -50,8 +55,18 @@ function ParseServer(args) { DatabaseAdapter.setAdapter(args.databaseAdapter); } + // Make files adapter let filesAdapter = args.filesAdapter || new GridStoreAdapter(); + // Make push adapter + let pushConfig = args.push; + let pushAdapter; + if (pushConfig && pushConfig.adapter) { + pushAdapter = pushConfig.adapter; + } else if (pushConfig) { + pushAdapter = new ParsePushAdapter(pushConfig) + } + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -117,13 +132,14 @@ function ParseServer(args) { router.merge(require('./sessions')); router.merge(require('./roles')); router.merge(require('./analytics')); - router.merge(require('./push').router); router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { router.merge(require('./global_config')); } + let pushController = new PushController(pushAdapter); + router.merge(pushController.getExpressRouter()); batch.mountOnto(router);