Upgrade APNS to use HTTP/2

- uses universal certificate
- removes tests logs
- standardized returned promises from APNS and GCM to something usable in _PushStatus
This commit is contained in:
Florent Vilmart
2016-03-13 18:15:15 -04:00
parent a392c088d8
commit dad50d12f5
13 changed files with 316 additions and 463 deletions

View File

@@ -18,7 +18,6 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"apn": "^1.7.5",
"aws-sdk": "~2.2.33", "aws-sdk": "~2.2.33",
"babel-polyfill": "^6.5.0", "babel-polyfill": "^6.5.0",
"babel-runtime": "^6.5.0", "babel-runtime": "^6.5.0",
@@ -29,6 +28,7 @@
"deepcopy": "^0.6.1", "deepcopy": "^0.6.1",
"express": "^4.13.4", "express": "^4.13.4",
"gcloud": "^0.28.0", "gcloud": "^0.28.0",
"http2": "^3.3.2",
"mailgun-js": "^0.7.7", "mailgun-js": "^0.7.7",
"mime": "^1.3.4", "mime": "^1.3.4",
"mongodb": "~2.1.0", "mongodb": "~2.1.0",

View File

@@ -1,3 +1,4 @@
'use strict';
var APNS = require('../src/APNS'); var APNS = require('../src/APNS');
describe('APNS', () => { describe('APNS', () => {
@@ -9,17 +10,13 @@ describe('APNS', () => {
production: true, production: true,
bundleId: 'bundleId' bundleId: 'bundleId'
} }
var apns = new APNS(args); var apns = APNS(args);
expect(apns.conns.length).toBe(1); var apnsConfiguration = apns.getConfiguration();
var apnsConnection = apns.conns[0]; expect(apnsConfiguration.bundleId).toBe(args.bundleId);
expect(apnsConnection.index).toBe(0); expect(apnsConfiguration.cert).toBe(args.cert);
expect(apnsConnection.bundleId).toBe(args.bundleId); expect(apnsConfiguration.key).toBe(args.key);
// TODO: Remove this checking onec we inject APNS expect(apnsConfiguration.production).toBe(args.production);
var prodApnsOptions = apnsConnection.options;
expect(prodApnsOptions.cert).toBe(args.cert);
expect(prodApnsOptions.key).toBe(args.key);
expect(prodApnsOptions.production).toBe(args.production);
done(); done();
}); });
@@ -39,24 +36,18 @@ describe('APNS', () => {
} }
] ]
var apns = new APNS(args); var apns = APNS(args);
expect(apns.conns.length).toBe(2); var devApnsConfiguration = apns.getConfiguration('bundleId');
var devApnsConnection = apns.conns[1]; expect(devApnsConfiguration.cert).toBe(args[0].cert);
expect(devApnsConnection.index).toBe(1); expect(devApnsConfiguration.key).toBe(args[0].key);
var devApnsOptions = devApnsConnection.options; expect(devApnsConfiguration.production).toBe(args[0].production);
expect(devApnsOptions.cert).toBe(args[0].cert); expect(devApnsConfiguration.bundleId).toBe(args[0].bundleId);
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]; var prodApnsConfiguration = apns.getConfiguration('bundleIdAgain');
expect(prodApnsConnection.index).toBe(0); expect(prodApnsConfiguration.cert).toBe(args[1].cert);
// TODO: Remove this checking onec we inject APNS expect(prodApnsConfiguration.key).toBe(args[1].key);
var prodApnsOptions = prodApnsConnection.options; expect(prodApnsConfiguration.production).toBe(args[1].production);
expect(prodApnsOptions.cert).toBe(args[1].cert); expect(prodApnsConfiguration.bundleId).toBe(args[1].bundleId);
expect(prodApnsOptions.key).toBe(args[1].key);
expect(prodApnsOptions.production).toBe(args[1].production);
expect(prodApnsOptions.bundleId).toBe(args[1].bundleId);
done(); done();
}); });
@@ -73,56 +64,14 @@ describe('APNS', () => {
}; };
var expirationTime = 1454571491354 var expirationTime = 1454571491354
var notification = APNS.generateNotification(data, expirationTime); var notification = APNS.generateNotification(data);
expect(notification.aps.alert).toEqual(data.alert);
expect(notification.alert).toEqual(data.alert); expect(notification.aps.badge).toEqual(data.badge);
expect(notification.badge).toEqual(data.badge); expect(notification.aps.sound).toEqual(data.sound);
expect(notification.sound).toEqual(data.sound); expect(notification.aps['content-available']).toEqual(1);
expect(notification.contentAvailable).toEqual(1); expect(notification.aps.category).toEqual(data.category);
expect(notification.category).toEqual(data.category); expect(notification.key).toEqual('value');
expect(notification.payload).toEqual({ expect(notification.keyAgain).toEqual('valueAgain');
'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]);
done(); done();
}); });
@@ -130,7 +79,7 @@ describe('APNS', () => {
// Mock conns // Mock conns
var conns = [ var conns = [
{ {
bundleId: 'bundleId' bundleId: 'bundleId',
}, },
{ {
bundleId: 'bundleIdAgain' bundleId: 'bundleIdAgain'
@@ -140,143 +89,18 @@ describe('APNS', () => {
var device = { var device = {
appIdentifier: 'invalid' appIdentifier: 'invalid'
}; };
let apns = APNS(conns);
var qualifiedConns = APNS.chooseConns(conns, device); var config = apns.getConfiguration(device.appIdentifier);
expect(qualifiedConns).toEqual([]); expect(config).toBeUndefined();
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(); done();
}); });
it('can send APNS notification', (done) => { it('can send APNS notification', (done) => {
var args = { var args = {
cert: 'prodCert.pem',
key: 'prodKey.pem',
production: true, production: true,
bundleId: 'bundleId' bundleId: 'bundleId'
} }
var apns = new APNS(args); var apns = APNS(args);
var conn = {
pushNotification: jasmine.createSpy('send'),
bundleId: 'bundleId'
};
apns.conns = [ conn ];
// Mock data // Mock data
var expirationTime = 1454571491354 var expirationTime = 1454571491354
var data = { var data = {
@@ -293,15 +117,18 @@ describe('APNS', () => {
} }
]; ];
var promise = apns.send(data, devices); apns.send(data, devices).then((results) => {
expect(conn.pushNotification).toHaveBeenCalled(); let isArray = Array.isArray(results);
var args = conn.pushNotification.calls.first().args; expect(isArray).toBe(true);
var notification = args[0]; expect(results.length).toBe(1);
expect(notification.alert).toEqual(data.data.alert); // No provided certificates
expect(notification.expiry).toEqual(data['expiration_time']); expect(results[0].status).toBe(403);
var apnDevice = args[1] expect(results[0].device).toEqual(devices[0]);
expect(apnDevice.connIndex).toEqual(0); expect(results[0].transmitted).toBe(false);
expect(apnDevice.appIdentifier).toEqual('bundleId'); done();
}, (err) => {
fail('should not fail');
done(); done();
}); });
}); });
});

View File

@@ -23,17 +23,15 @@ describe('GCM', () => {
var data = { var data = {
'alert': 'alert' 'alert': 'alert'
}; };
var pushId = 1;
var timeStamp = 1454538822113; var timeStamp = 1454538822113;
var timeStampISOStr = new Date(timeStamp).toISOString(); 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.priority).toEqual('normal');
expect(payload.timeToLive).toEqual(undefined); expect(payload.timeToLive).toEqual(undefined);
var dataFromPayload = payload.data; var dataFromPayload = payload.data;
expect(dataFromPayload.time).toEqual(timeStampISOStr); expect(dataFromPayload.time).toEqual(timeStampISOStr);
expect(dataFromPayload['push_id']).toEqual(pushId);
var dataFromUser = JSON.parse(dataFromPayload.data); var dataFromUser = JSON.parse(dataFromPayload.data);
expect(dataFromUser).toEqual(data); expect(dataFromUser).toEqual(data);
done(); done();
@@ -44,18 +42,16 @@ describe('GCM', () => {
var data = { var data = {
'alert': 'alert' 'alert': 'alert'
}; };
var pushId = 1;
var timeStamp = 1454538822113; var timeStamp = 1454538822113;
var timeStampISOStr = new Date(timeStamp).toISOString(); var timeStampISOStr = new Date(timeStamp).toISOString();
var expirationTime = 1454538922113 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.priority).toEqual('normal');
expect(payload.timeToLive).toEqual(Math.floor((expirationTime - timeStamp) / 1000)); expect(payload.timeToLive).toEqual(Math.floor((expirationTime - timeStamp) / 1000));
var dataFromPayload = payload.data; var dataFromPayload = payload.data;
expect(dataFromPayload.time).toEqual(timeStampISOStr); expect(dataFromPayload.time).toEqual(timeStampISOStr);
expect(dataFromPayload['push_id']).toEqual(pushId);
var dataFromUser = JSON.parse(dataFromPayload.data); var dataFromUser = JSON.parse(dataFromPayload.data);
expect(dataFromUser).toEqual(data); expect(dataFromUser).toEqual(data);
done(); done();
@@ -66,18 +62,16 @@ describe('GCM', () => {
var data = { var data = {
'alert': 'alert' 'alert': 'alert'
}; };
var pushId = 1;
var timeStamp = 1454538822113; var timeStamp = 1454538822113;
var timeStampISOStr = new Date(timeStamp).toISOString(); var timeStampISOStr = new Date(timeStamp).toISOString();
var expirationTime = 1454538822112; 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.priority).toEqual('normal');
expect(payload.timeToLive).toEqual(0); expect(payload.timeToLive).toEqual(0);
var dataFromPayload = payload.data; var dataFromPayload = payload.data;
expect(dataFromPayload.time).toEqual(timeStampISOStr); expect(dataFromPayload.time).toEqual(timeStampISOStr);
expect(dataFromPayload['push_id']).toEqual(pushId);
var dataFromUser = JSON.parse(dataFromPayload.data); var dataFromUser = JSON.parse(dataFromPayload.data);
expect(dataFromUser).toEqual(data); expect(dataFromUser).toEqual(data);
done(); done();
@@ -88,19 +82,17 @@ describe('GCM', () => {
var data = { var data = {
'alert': 'alert' 'alert': 'alert'
}; };
var pushId = 1;
var timeStamp = 1454538822113; var timeStamp = 1454538822113;
var timeStampISOStr = new Date(timeStamp).toISOString(); var timeStampISOStr = new Date(timeStamp).toISOString();
var expirationTime = 2454538822113; var expirationTime = 2454538822113;
var payload = GCM.generateGCMPayload(data, pushId, timeStamp, expirationTime); var payload = GCM.generateGCMPayload(data, timeStamp, expirationTime);
expect(payload.priority).toEqual('normal'); expect(payload.priority).toEqual('normal');
// Four week in second // Four week in second
expect(payload.timeToLive).toEqual(4 * 7 * 24 * 60 * 60); expect(payload.timeToLive).toEqual(4 * 7 * 24 * 60 * 60);
var dataFromPayload = payload.data; var dataFromPayload = payload.data;
expect(dataFromPayload.time).toEqual(timeStampISOStr); expect(dataFromPayload.time).toEqual(timeStampISOStr);
expect(dataFromPayload['push_id']).toEqual(pushId);
var dataFromUser = JSON.parse(dataFromPayload.data); var dataFromUser = JSON.parse(dataFromPayload.data);
expect(dataFromUser).toEqual(data); expect(dataFromUser).toEqual(data);
done(); done();
@@ -139,6 +131,46 @@ describe('GCM', () => {
done(); 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) => { it('can slice devices', (done) => {
// Mock devices // Mock devices
var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)];

View File

@@ -3,18 +3,20 @@ describe('Parse.Push', () => {
var pushAdapter = { var pushAdapter = {
send: function(body, installations) { send: function(body, installations) {
var badge = body.data.badge; var badge = body.data.badge;
installations.forEach((installation) => { let promises = installations.map((installation) => {
if (installation.deviceType == "ios") { if (installation.deviceType == "ios") {
expect(installation.badge).toEqual(badge); expect(installation.badge).toEqual(badge);
expect(installation.originalBadge+1).toEqual(installation.badge); expect(installation.originalBadge+1).toEqual(installation.badge);
} else { } else {
expect(installation.badge).toBeUndefined(); expect(installation.badge).toBeUndefined();
} }
});
return Promise.resolve({ return Promise.resolve({
body: body, err: null,
installations: installations deviceType: installation.deviceType,
result: true
})
}); });
return Promise.all(promises)
}, },
getValidPushTypes: function() { getValidPushTypes: function() {
return ["ios", "android"]; return ["ios", "android"];

View File

@@ -1153,7 +1153,6 @@ describe('Parse.ACL', () => {
var query = new Parse.Query("TestClassMasterACL"); var query = new Parse.Query("TestClassMasterACL");
return query.find(); return query.find();
}).then((results) => { }).then((results) => {
console.log(JSON.stringify(results[0]));
ok(!results.length, 'Should not have returned object with secure ACL.'); ok(!results.length, 'Should not have returned object with secure ACL.');
done(); done();
}); });

View File

@@ -29,7 +29,9 @@ describe('ParsePushAdapter', () => {
var parsePushAdapter = new ParsePushAdapter(pushConfig); var parsePushAdapter = new ParsePushAdapter(pushConfig);
// Check ios // Check ios
var iosSender = parsePushAdapter.senderMap['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 // Check android
var androidSender = parsePushAdapter.senderMap['android']; var androidSender = parsePushAdapter.senderMap['android'];
expect(androidSender instanceof GCM).toBe(true); expect(androidSender instanceof GCM).toBe(true);

View File

@@ -143,7 +143,7 @@ describe('PushController', () => {
} }
}) })
return Promise.resolve({ return Promise.resolve({
error: null error: null,
payload: body, payload: body,
}) })
}, },

View File

@@ -1,9 +1,30 @@
"use strict"; "use strict";
const Parse = require('parse/node').Parse; const Parse = require('parse/node').Parse;
// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, const http = require('http2');
// but probably we will replace it in the future. const fs = require('fs');
const apn = require('apn'); 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. * 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 {String} args.bundleId The bundleId for cert
* @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox
*/ */
function APNS(args) { function APNS(options) {
// Since for ios, there maybe multiple cert/key pairs,
// typePushConfig can be an array. if (!Array.isArray(options)) {
let apnsArgsList = []; options = [options];
if (Array.isArray(args)) { }
apnsArgsList = apnsArgsList.concat(args);
} else if (typeof args === 'object') { let agents = {};
apnsArgsList.push(args);
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 { } else {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, throw 'Either cert AND key, OR pfx is required'
'APNS Configuration is invalid');
} }
} catch(e) {
this.conns = []; if (!process.env.NODE_ENV == 'test' || options.enforceCertificates) {
for (let apnsArgs of apnsArgsList) { throw e;
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;
} }
option.agent = new http.Agent({
// Set apns client callbacks key: option.key,
conn.on('connected', () => { cert: option.cert,
console.log('APNS Connection %d Connected', conn.index); pfx: option.pfx,
passphrase: option.passphrase
}); });
memo[option.bundleId] = option;
return memo;
}, {});
conn.on('transmissionError', (errCode, notification, apnDevice) => { let getConfiguration = (bundleIdentifier) => {
handleTransmissionError(this.conns, errCode, notification, apnDevice); let configuration;
}); if (bundleIdentifier) {
configuration = optionsByBundle[bundleIdentifier];
conn.on('timeout', () => { if (!configuration) {
console.log('APNS Connection %d Timeout', conn.index); return;
});
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
});
} }
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 if (!configuration) {
this.conns.sort((s1, s2) => { configuration = options[0];
return s1.priority - s2.priority;
});
// Set index of conns
for (let index = 0; index < this.conns.length; index++) {
this.conns[index].index = index;
} }
return configuration;
} }
/** /**
* Send apns request. * Send apns request.
* @param {Object} data The data we need to send, the format is the same with api request body * @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 * @param {Array} devices A array of device tokens
* @returns {Object} A promise which is resolved immediately * @returns {Object} A promises that resolves with each notificaiton sending promise
*/ */
APNS.prototype.send = function(data, devices) { let send = function(data, devices) {
// Make sure devices are in an array
if (!Array.isArray(devices)) {
devices = [devices];
}
let coreData = data.data; let coreData = data.data;
let expirationTime = data['expiration_time']; let expirationTime = data['expiration_time'];
let notification = generateNotification(coreData, expirationTime); let notification = generateNotification(coreData);
let notificationString = JSON.stringify(notification);
let buffer = new Buffer(notificationString);
let promises = devices.map((device) => { 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) => { return new Promise((resolve, reject) => {
apnDevice.callback = (err, res) => { let configuration = getConfiguration(device.appIdentifier);
resolve({ if (!configuration) {
error: err, return Promise.reject({
response: res, status: -1,
payload: notification, device: device,
deviceType: 'ios' response: {"error": "No configuration set for that appIdentifier"},
}); transmitted: false
})
} }
conn.pushNotification(notification, apnDevice); 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 });
}); });
}); });
return Parse.Promise.when(promises); req.write(buffer);
} req.end();
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) {
if (apnDevice.callback) {
apnDevice.callback({
error: `APNS can not find vaild connection for ${apnDevice.token}`,
code: errCode
}); });
} });
return; return Promise.all(promises);
} }
let newConn = conns[newConnIndex]; return Object.freeze({
// Update device conn info send: send,
apnDevice.connIndex = newConnIndex; getConfiguration: getConfiguration
// 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;
} }
/** /**
@@ -193,12 +154,12 @@ function chooseConns(conns, device) {
* @returns {Object} A apns notification * @returns {Object} A apns notification
*/ */
function generateNotification(coreData, expirationTime) { function generateNotification(coreData, expirationTime) {
let notification = new apn.notification();
let payload = {}; let payload = {};
let notification = {};
for (let key in coreData) { for (let key in coreData) {
switch (key) { switch (key) {
case 'alert': case 'alert':
notification.setAlertText(coreData.alert); notification.alert = coreData.alert;
break; break;
case 'badge': case 'badge':
notification.badge = coreData.badge; notification.badge = coreData.badge;
@@ -207,9 +168,10 @@ function generateNotification(coreData, expirationTime) {
notification.sound = coreData.sound; notification.sound = coreData.sound;
break; break;
case 'content-available': case 'content-available':
notification.setNewsstandAvailable(true);
let isAvailable = coreData['content-available'] === 1; let isAvailable = coreData['content-available'] === 1;
notification.setContentAvailable(isAvailable); if (isAvailable) {
notification['content-available'] = 1;
}
break; break;
case 'category': case 'category':
notification.category = coreData.category; notification.category = coreData.category;
@@ -219,14 +181,11 @@ function generateNotification(coreData, expirationTime) {
break; break;
} }
} }
notification.payload = payload; payload.aps = notification;
notification.expiry = expirationTime; return payload;
return notification;
} }
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
APNS.generateNotification = generateNotification; APNS.generateNotification = generateNotification;
APNS.chooseConns = chooseConns;
APNS.handleTransmissionError = handleTransmissionError;
} }
module.exports = APNS; module.exports = APNS;

View File

@@ -30,7 +30,6 @@ export class OneSignalPushAdapter extends PushAdapter {
} }
send(data, installations) { send(data, installations) {
console.log("Sending notification to "+installations.length+" devices.")
let deviceMap = classifyInstallations(installations, this.validPushTypes); let deviceMap = classifyInstallations(installations, this.validPushTypes);
let sendPromises = []; let sendPromises = [];

View File

@@ -50,12 +50,15 @@ export class ParsePushAdapter extends PushAdapter {
for (let pushType in deviceMap) { for (let pushType in deviceMap) {
let sender = this.senderMap[pushType]; let sender = this.senderMap[pushType];
if (!sender) { if (!sender) {
console.log('Can not find sender for push type %s, %j', pushType, data); sendPromises.push(Promise.resolve({
continue; transmitted: false,
} response: {'error': `Can not find sender for push type ${pushType}, ${data}`}
}))
} else {
let devices = deviceMap[pushType]; let devices = deviceMap[pushType];
sendPromises.push(sender.send(data, devices)); sendPromises.push(sender.send(data, devices));
} }
}
return Parse.Promise.when(sendPromises); return Parse.Promise.when(sendPromises);
} }
} }

View File

@@ -21,10 +21,7 @@ export function classifyInstallations(installations, validPushTypes) {
deviceToken: installation.deviceToken, deviceToken: installation.deviceToken,
appIdentifier: installation.appIdentifier appIdentifier: installation.appIdentifier
}); });
} else {
console.log('Unknown push type from installation %j', installation);
} }
} }
return deviceMap; return deviceMap;
} }

View File

@@ -119,7 +119,7 @@ export class PushController extends AdaptableController {
} }
return pushAdapter.send(body, response.results, pushStatus); return pushAdapter.send(body, response.results, pushStatus);
}).then((results) => { }).then((results) => {
console.log(results); // TODO: handle push results
return Promise.resolve(results); return Promise.resolve(results);
}); });
} }

View File

@@ -21,9 +21,30 @@ function GCM(args) {
* @param {Array} devices A array of devices * @param {Array} devices A array of devices
* @returns {Object} A promise which is resolved after we get results from gcm * @returns {Object} A promise which is resolved after we get results from gcm
*/ */
GCM.prototype.send = function(data, devices, callback) { GCM.prototype.send = function(data, devices) {
let pushId = cryptoUtils.newObjectId(); // Make a new array
let timeStamp = Date.now(); 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; let expirationTime;
// We handle the expiration_time convertion in push.js, so expiration_time is a valid date // We handle the expiration_time convertion in push.js, so expiration_time is a valid date
// in Unix epoch time in milliseconds here // in Unix epoch time in milliseconds here
@@ -31,38 +52,51 @@ GCM.prototype.send = function(data, devices, callback) {
expirationTime = data['expiration_time']; expirationTime = data['expiration_time'];
} }
// Generate gcm payload // Generate gcm payload
let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); let gcmPayload = generateGCMPayload(data.data, timestamp, expirationTime);
// Make and send gcm request // Make and send gcm request
let message = new gcm.Message(gcmPayload); let message = new gcm.Message(gcmPayload);
let sendPromises = []; // Build a device map
// For android, we can only have 1000 recepients per send, so we need to slice devices to let devicesMap = devices.reduce((memo, device) => {
// chunk if necessary memo[device.deviceToken] = device;
let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax); return memo;
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);
}
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 * @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 * @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 = { let payloadData = {
'time': new Date(timeStamp).toISOString(), 'time': new Date(timeStamp).toISOString(),
'push_id': pushId,
'data': JSON.stringify(coreData) 'data': JSON.stringify(coreData)
} }
let payload = { let payload = {