From dad50d12f5226b3b076a692c9844a2664ca13296 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 13 Mar 2016 18:15:15 -0400 Subject: [PATCH] Upgrade APNS to use HTTP/2 - uses universal certificate - removes tests logs - standardized returned promises from APNS and GCM to something usable in _PushStatus --- package.json | 2 +- spec/APNS.spec.js | 261 ++++--------------- spec/GCM.spec.js | 56 +++- spec/Parse.Push.spec.js | 14 +- spec/ParseACL.spec.js | 1 - spec/ParsePushAdapter.spec.js | 4 +- spec/PushController.spec.js | 2 +- src/APNS.js | 297 ++++++++++------------ src/Adapters/Push/OneSignalPushAdapter.js | 27 +- src/Adapters/Push/ParsePushAdapter.js | 11 +- src/Adapters/Push/PushAdapterUtils.js | 3 - src/Controllers/PushController.js | 2 +- src/GCM.js | 99 +++++--- 13 files changed, 316 insertions(+), 463 deletions(-) diff --git a/package.json b/package.json index 703fb401..ccc9bb55 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "apn": "^1.7.5", "aws-sdk": "~2.2.33", "babel-polyfill": "^6.5.0", "babel-runtime": "^6.5.0", @@ -29,6 +28,7 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", + "http2": "^3.3.2", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index c56e35d5..459071ed 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,3 +1,4 @@ +'use strict'; var APNS = require('../src/APNS'); describe('APNS', () => { @@ -9,17 +10,13 @@ describe('APNS', () => { production: true, bundleId: 'bundleId' } - var apns = new APNS(args); + var apns = 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); + var apnsConfiguration = apns.getConfiguration(); + expect(apnsConfiguration.bundleId).toBe(args.bundleId); + expect(apnsConfiguration.cert).toBe(args.cert); + expect(apnsConfiguration.key).toBe(args.key); + expect(apnsConfiguration.production).toBe(args.production); done(); }); @@ -39,24 +36,18 @@ describe('APNS', () => { } ] - 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 apns = APNS(args); + var devApnsConfiguration = apns.getConfiguration('bundleId'); + expect(devApnsConfiguration.cert).toBe(args[0].cert); + expect(devApnsConfiguration.key).toBe(args[0].key); + expect(devApnsConfiguration.production).toBe(args[0].production); + expect(devApnsConfiguration.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); + var prodApnsConfiguration = apns.getConfiguration('bundleIdAgain'); + expect(prodApnsConfiguration.cert).toBe(args[1].cert); + expect(prodApnsConfiguration.key).toBe(args[1].key); + expect(prodApnsConfiguration.production).toBe(args[1].production); + expect(prodApnsConfiguration.bundleId).toBe(args[1].bundleId); done(); }); @@ -73,56 +64,14 @@ describe('APNS', () => { }; var expirationTime = 1454571491354 - var notification = APNS.generateNotification(data, expirationTime); - - expect(notification.alert).toEqual(data.alert); - expect(notification.badge).toEqual(data.badge); - expect(notification.sound).toEqual(data.sound); - expect(notification.contentAvailable).toEqual(1); - expect(notification.category).toEqual(data.category); - expect(notification.payload).toEqual({ - 'key': 'value', - 'keyAgain': 'valueAgain' - }); - expect(notification.expiry).toEqual(expirationTime); - 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]); + var notification = APNS.generateNotification(data); + expect(notification.aps.alert).toEqual(data.alert); + expect(notification.aps.badge).toEqual(data.badge); + expect(notification.aps.sound).toEqual(data.sound); + expect(notification.aps['content-available']).toEqual(1); + expect(notification.aps.category).toEqual(data.category); + expect(notification.key).toEqual('value'); + expect(notification.keyAgain).toEqual('valueAgain'); done(); }); @@ -130,7 +79,7 @@ describe('APNS', () => { // Mock conns var conns = [ { - bundleId: 'bundleId' + bundleId: 'bundleId', }, { bundleId: 'bundleIdAgain' @@ -140,143 +89,18 @@ describe('APNS', () => { 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(); + let apns = APNS(conns); + var config = apns.getConfiguration(device.appIdentifier); + expect(config).toBeUndefined(); done(); }); it('can send APNS notification', (done) => { 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.conns = [ conn ]; + var apns = APNS(args); // Mock data var expirationTime = 1454571491354 var data = { @@ -293,15 +117,18 @@ describe('APNS', () => { } ]; - 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']); - var apnDevice = args[1] - expect(apnDevice.connIndex).toEqual(0); - expect(apnDevice.appIdentifier).toEqual('bundleId'); - done(); + apns.send(data, devices).then((results) => { + let isArray = Array.isArray(results); + expect(isArray).toBe(true); + expect(results.length).toBe(1); + // No provided certificates + expect(results[0].status).toBe(403); + expect(results[0].device).toEqual(devices[0]); + expect(results[0].transmitted).toBe(false); + done(); + }, (err) => { + fail('should not fail'); + done(); + }); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 30f1a997..ceb15368 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -23,17 +23,15 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); - var payload = GCM.generateGCMPayload(data, pushId, timeStamp); + var payload = GCM.generateGCMPayload(data, timeStamp); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(undefined); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -44,18 +42,16 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 1454538922113 - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(Math.floor((expirationTime - timeStamp) / 1000)); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -66,18 +62,16 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 1454538822112; - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); expect(payload.timeToLive).toEqual(0); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -88,19 +82,17 @@ describe('GCM', () => { var data = { 'alert': 'alert' }; - var pushId = 1; var timeStamp = 1454538822113; var timeStampISOStr = new Date(timeStamp).toISOString(); var expirationTime = 2454538822113; - var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); + var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime); expect(payload.priority).toEqual('normal'); // Four week in second expect(payload.timeToLive).toEqual(4 * 7 * 24 * 60 * 60); var dataFromPayload = payload.data; expect(dataFromPayload.time).toEqual(timeStampISOStr); - expect(dataFromPayload['push_id']).toEqual(pushId); var dataFromUser = JSON.parse(dataFromPayload.data); expect(dataFromUser).toEqual(data); done(); @@ -139,6 +131,46 @@ describe('GCM', () => { done(); }); + it('can send GCM request', (done) => { + var gcm = new GCM({ + apiKey: 'apiKey' + }); + // Mock data + var expirationTime = 2454538822113; + var data = { + 'expiration_time': expirationTime, + 'data': { + 'alert': 'alert' + } + } + // Mock devices + var devices = [ + { + deviceToken: 'token' + }, + { + deviceToken: 'token2' + }, + { + deviceToken: 'token3' + }, + { + deviceToken: 'token4' + } + ]; + + gcm.send(data, devices).then((response) => { + expect(Array.isArray(response)).toBe(true); + expect(response.length).toEqual(devices.length); + expect(response.length).toEqual(4); + response.forEach((res, index) => { + expect(res.transmitted).toEqual(false); + expect(res.device).toEqual(devices[index]); + }) + done(); + }) + }); + it('can slice devices', (done) => { // Mock devices var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; diff --git a/spec/Parse.Push.spec.js b/spec/Parse.Push.spec.js index a2b71d5f..c9f91253 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -3,18 +3,20 @@ describe('Parse.Push', () => { var pushAdapter = { send: function(body, installations) { var badge = body.data.badge; - installations.forEach((installation) => { + let promises = installations.map((installation) => { if (installation.deviceType == "ios") { expect(installation.badge).toEqual(badge); expect(installation.originalBadge+1).toEqual(installation.badge); } else { expect(installation.badge).toBeUndefined(); } + return Promise.resolve({ + err: null, + deviceType: installation.deviceType, + result: true + }) }); - return Promise.resolve({ - body: body, - installations: installations - }); + return Promise.all(promises) }, getValidPushTypes: function() { return ["ios", "android"]; @@ -56,4 +58,4 @@ describe('Parse.Push', () => { done(); }); }); -}); \ No newline at end of file +}); diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index 62b30b06..3fe5656e 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -1153,7 +1153,6 @@ describe('Parse.ACL', () => { var query = new Parse.Query("TestClassMasterACL"); return query.find(); }).then((results) => { - console.log(JSON.stringify(results[0])); ok(!results.length, 'Should not have returned object with secure ACL.'); done(); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index e21a9dbb..cb08845c 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -29,7 +29,9 @@ describe('ParsePushAdapter', () => { var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSender = parsePushAdapter.senderMap['ios']; - expect(iosSender instanceof APNS).toBe(true); + expect(iosSender).not.toBe(undefined); + expect(typeof iosSender.send).toEqual('function'); + expect(typeof iosSender.getConfiguration).toEqual('function'); // Check android var androidSender = parsePushAdapter.senderMap['android']; expect(androidSender instanceof GCM).toBe(true); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 97dc18c5..317678c1 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -143,7 +143,7 @@ describe('PushController', () => { } }) return Promise.resolve({ - error: null + error: null, payload: body, }) }, diff --git a/src/APNS.js b/src/APNS.js index 345e3dbe..f95a6929 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,9 +1,30 @@ "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. -const apn = require('apn'); +const http = require('http2'); +const fs = require('fs'); +const path = require('path'); +const urlParse = require('url').parse; + +const DEV_PUSH_SERVER = 'api.development.push.apple.com'; +const PROD_PUSH_SERVER = 'api.push.apple.com'; + +const createRequestOptions = (opts, device, body) => { + let domain = opts.production === true ? PROD_PUSH_SERVER : DEV_PUSH_SERVER; + var options = urlParse(`https://${domain}/3/device/${device.deviceToken}`); + options.method = 'POST'; + options.headers = { + 'apns-expiration': opts.expiration || 0, + 'apns-priority': opts.priority || 10, + 'apns-topic': opts.bundleId || opts['apns-topic'], + 'content-length': body.length + }; + options.key = opts.key; + options.cert = opts.cert; + options.pfx = opts.pfx; + options.passphrase = opts.passphrase; + return Object.assign({}, options); +} /** * Create a new connection to the APN service. @@ -16,175 +37,115 @@ const apn = require('apn'); * @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) { - // 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'); +function APNS(options) { + + if (!Array.isArray(options)) { + options = [options]; } - 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; - } + let agents = {}; - // Set apns client callbacks - conn.on('connected', () => { - console.log('APNS Connection %d Connected', conn.index); - }); - - conn.on('transmissionError', (errCode, notification, apnDevice) => { - handleTransmissionError(this.conns, errCode, notification, apnDevice); - }); - - conn.on('timeout', () => { - console.log('APNS Connection %d Timeout', conn.index); - }); - - 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) { - if (device.callback) { - device.callback(null, { - notification: notification, - device: device - }); + let optionsByBundle = options.reduce((memo, option) => { + try { + if (option.key && option.cert) { + option.key = fs.readFileSync(option.key); + option.cert = fs.readFileSync(option.cert); + } else if (option.pfx) { + option.pfx = fs.readFileSync(option.pfx); + } else { + throw 'Either cert AND key, OR pfx is required' } - 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} devices A array of devices - * @returns {Object} A promise which is resolved immediately - */ -APNS.prototype.send = function(data, devices) { - let coreData = data.data; - let expirationTime = data['expiration_time']; - let notification = generateNotification(coreData, expirationTime); - - let promises = devices.map((device) => { - let qualifiedConnIndexs = chooseConns(this.conns, device); - // We can not find a valid conn, just ignore this device - if (qualifiedConnIndexs.length == 0) { - return Promise.resolve({ - err: 'No connection available' - }); - } - 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; - } - return new Promise((resolve, reject) => { - apnDevice.callback = (err, res) => { - resolve({ - error: err, - response: res, - payload: notification, - deviceType: 'ios' - }); + } catch(e) { + if (!process.env.NODE_ENV == 'test' || options.enforceCertificates) { + throw e; } - conn.pushNotification(notification, apnDevice); + } + option.agent = new http.Agent({ + key: option.key, + cert: option.cert, + pfx: option.pfx, + passphrase: option.passphrase }); - }); - return Parse.Promise.when(promises); -} + memo[option.bundleId] = option; + return memo; + }, {}); -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; + let getConfiguration = (bundleIdentifier) => { + let configuration; + if (bundleIdentifier) { + configuration = optionsByBundle[bundleIdentifier]; + if (!configuration) { + return; + } } + if (!configuration) { + configuration = options[0]; + } + return configuration; } - // There is no more available conns, we give up in this case - if (newConnIndex < 0 || newConnIndex >= conns.length) { - if (apnDevice.callback) { - apnDevice.callback({ - error: `APNS can not find vaild connection for ${apnDevice.token}`, - code: errCode + + /** + * Send apns request. + * @param {Object} data The data we need to send, the format is the same with api request body + * @param {Array} devices A array of device tokens + * @returns {Object} A promises that resolves with each notificaiton sending promise + */ + let send = function(data, devices) { + // Make sure devices are in an array + if (!Array.isArray(devices)) { + devices = [devices]; + } + + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData); + let notificationString = JSON.stringify(notification); + let buffer = new Buffer(notificationString); + + let promises = devices.map((device) => { + return new Promise((resolve, reject) => { + let configuration = getConfiguration(device.appIdentifier); + if (!configuration) { + return Promise.reject({ + status: -1, + device: device, + response: {"error": "No configuration set for that appIdentifier"}, + transmitted: false + }) + } + configuration = Object.assign({}, configuration, {expiration: expirationTime }) + let requestOptions = createRequestOptions(configuration, device, buffer); + let req = configuration.agent.request(requestOptions, (response) => { + response.setEncoding('utf8'); + var chunks = ""; + response.on('data', (chunk) => { + chunks+=chunk; + }); + response.on('end', () => { + let body; + try{ + body = JSON.parse(chunks); + } catch (e) { + body = {}; + } + resolve({ status: response.statusCode, + response: body, + headers: response.headers, + device: device, + transmitted: response.statusCode == 200 }); + }); + }); + req.write(buffer); + req.end(); }); - } - return; + }); + return Promise.all(promises); } - 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; + return Object.freeze({ + send: send, + getConfiguration: getConfiguration + }) } /** @@ -193,12 +154,12 @@ function chooseConns(conns, device) { * @returns {Object} A apns notification */ function generateNotification(coreData, expirationTime) { - let notification = new apn.notification(); let payload = {}; + let notification = {}; for (let key in coreData) { switch (key) { case 'alert': - notification.setAlertText(coreData.alert); + notification.alert = coreData.alert; break; case 'badge': notification.badge = coreData.badge; @@ -207,9 +168,10 @@ function generateNotification(coreData, expirationTime) { notification.sound = coreData.sound; break; case 'content-available': - notification.setNewsstandAvailable(true); let isAvailable = coreData['content-available'] === 1; - notification.setContentAvailable(isAvailable); + if (isAvailable) { + notification['content-available'] = 1; + } break; case 'category': notification.category = coreData.category; @@ -219,14 +181,11 @@ function generateNotification(coreData, expirationTime) { break; } } - notification.payload = payload; - notification.expiry = expirationTime; - return notification; + payload.aps = notification; + return payload; } 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/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index b92d00c5..7c4d6062 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -10,11 +10,11 @@ var deepcopy = require('deepcopy'); import PushAdapter from './PushAdapter'; export class OneSignalPushAdapter extends PushAdapter { - + constructor(pushConfig = {}) { super(pushConfig); this.https = require('https'); - + this.validPushTypes = ['ios', 'android']; this.senderMap = {}; this.OneSignalConfig = {}; @@ -24,13 +24,12 @@ export class OneSignalPushAdapter extends PushAdapter { } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; - + this.senderMap['ios'] = this.sendToAPNS.bind(this); this.senderMap['android'] = this.sendToGCM.bind(this); } - + send(data, installations) { - console.log("Sending notification to "+installations.length+" devices.") let deviceMap = classifyInstallations(installations, this.validPushTypes); let sendPromises = []; @@ -48,15 +47,15 @@ export class OneSignalPushAdapter extends PushAdapter { } return Parse.Promise.when(sendPromises); } - + static classifyInstallations(installations, validTypes) { return classifyInstallations(installations, validTypes) } - + getValidPushTypes() { return this.validPushTypes; } - + sendToAPNS(data,tokens) { data= deepcopy(data['data']); @@ -117,19 +116,19 @@ export class OneSignalPushAdapter extends PushAdapter { return promise; } - + sendToGCM(data,tokens) { data= deepcopy(data['data']); var post = {}; - + if(data['alert']) { post['contents'] = {en: data['alert']}; delete data['alert']; } if(data['title']) { post['title'] = {en: data['title']}; - delete data['title']; + delete data['title']; } if(data['uri']) { post['url'] = data['uri']; @@ -155,7 +154,7 @@ export class OneSignalPushAdapter extends PushAdapter { } }.bind(this); - this.sendNext = function() { + this.sendNext = function() { post['include_android_reg_ids'] = []; tokens.slice(offset,offset+chunk).forEach(function(i) { post['include_android_reg_ids'].push(i['deviceToken']) @@ -168,7 +167,7 @@ export class OneSignalPushAdapter extends PushAdapter { this.sendNext(); return promise; } - + sendToOneSignal(data, cb) { let headers = { "Content-Type": "application/json", @@ -188,7 +187,7 @@ export class OneSignalPushAdapter extends PushAdapter { cb(true); } else { console.log('OneSignal Error'); - res.on('data', function(chunk) { + res.on('data', function(chunk) { console.log(chunk.toString()) }); cb(false) diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index d49c1b1c..7b3cfa03 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -50,11 +50,14 @@ export class ParsePushAdapter extends PushAdapter { 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; + sendPromises.push(Promise.resolve({ + transmitted: false, + response: {'error': `Can not find sender for push type ${pushType}, ${data}`} + })) + } else { + let devices = deviceMap[pushType]; + sendPromises.push(sender.send(data, devices)); } - let devices = deviceMap[pushType]; - sendPromises.push(sender.send(data, devices)); } return Parse.Promise.when(sendPromises); } diff --git a/src/Adapters/Push/PushAdapterUtils.js b/src/Adapters/Push/PushAdapterUtils.js index a78aab42..6a9216ec 100644 --- a/src/Adapters/Push/PushAdapterUtils.js +++ b/src/Adapters/Push/PushAdapterUtils.js @@ -21,10 +21,7 @@ export function classifyInstallations(installations, validPushTypes) { deviceToken: installation.deviceToken, appIdentifier: installation.appIdentifier }); - } else { - console.log('Unknown push type from installation %j', installation); } } return deviceMap; } - diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index ae2b5318..1c5edc89 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -119,7 +119,7 @@ export class PushController extends AdaptableController { } return pushAdapter.send(body, response.results, pushStatus); }).then((results) => { - console.log(results); + // TODO: handle push results return Promise.resolve(results); }); } diff --git a/src/GCM.js b/src/GCM.js index 473b5ed9..e3df5979 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -21,9 +21,30 @@ function GCM(args) { * @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, devices, callback) { - let pushId = cryptoUtils.newObjectId(); - let timeStamp = Date.now(); +GCM.prototype.send = function(data, devices) { + // Make a new array + devices = new Array(...devices); + let timestamp = Date.now(); + // For android, we can only have 1000 recepients per send, so we need to slice devices to + // chunk if necessary + let slices = sliceDevices(devices, GCMRegistrationTokensMax); + if (slices.length > 1) { + // Make 1 send per slice + let promises = slices.reduce((memo, slice) => { + let promise = this.send(data, slice, timestamp); + memo.push(promise); + return memo; + }, []) + return Parse.Promise.when(promises).then((results) => { + let allResults = results.reduce((memo, result) => { + return memo.concat(result); + }, []); + return Parse.Promise.as(allResults); + }); + } + // get the devices back... + devices = slices[0]; + 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 @@ -31,38 +52,51 @@ GCM.prototype.send = function(data, devices, callback) { expirationTime = data['expiration_time']; } // Generate gcm payload - let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, timestamp, expirationTime); // Make and send gcm request 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({ - error: error, - response: response, - payload: message, - deviceType: 'android' - }); - }); - sendPromises.push(sendPromise); - } + // Build a device map + let devicesMap = devices.reduce((memo, device) => { + memo[device.deviceToken] = device; + return memo; + }, {}); - return Parse.Promise.when(sendPromises); + let deviceTokens = Object.keys(devicesMap); + + let promises = deviceTokens.map(() => new Parse.Promise()); + let registrationTokens = deviceTokens; + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { + // example response: + /* + { "multicast_id":7680139367771848000, + "success":0, + "failure":4, + "canonical_ids":0, + "results":[ {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}, + {"error":"InvalidRegistration"}] } + */ + let { results, multicast_id } = response || {}; + registrationTokens.forEach((token, index) => { + let promise = promises[index]; + let result = results ? results[index] : undefined; + let device = devicesMap[token]; + let resolution = { + device, + multicast_id, + response: error || result, + }; + if (!result || result.error) { + resolution.transmitted = false; + } else { + resolution.transmitted = true; + } + promise.resolve(resolution); + }); + }); + return Parse.Promise.when(promises); } /** @@ -73,10 +107,9 @@ GCM.prototype.send = function(data, devices, callback) { * @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 */ -function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) { +function generateGCMPayload(coreData, timeStamp, expirationTime) { let payloadData = { 'time': new Date(timeStamp).toISOString(), - 'push_id': pushId, 'data': JSON.stringify(coreData) } let payload = {