diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js new file mode 100644 index 0000000000..e7f3176871 --- /dev/null +++ b/spec/OneSignalPushAdapter.spec.js @@ -0,0 +1,234 @@ + +var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); + +describe('OneSignalPushAdapter', () => { + it('can be initialized', (done) => { + // Make mock config + var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" + }; + + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); + + var senderMap = oneSignalPushAdapter.senderMap; + + expect(senderMap.ios instanceof Function).toBe(true); + expect(senderMap.android instanceof Function).toBe(true); + done(); + }); + + it('can get valid push types', (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + + expect(oneSignalPushAdapter.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 = OneSignalPushAdapter.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 oneSignalPushAdapter = new OneSignalPushAdapter(); + + // Mock android ios senders + var androidSender = jasmine.createSpy('send') + var iosSender = jasmine.createSpy('send') + + var senderMap = { + ios: iosSender, + android: androidSender + }; + oneSignalPushAdapter.senderMap = senderMap; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + oneSignalPushAdapter.send(data, installations); + // Check android sender + expect(androidSender).toHaveBeenCalled(); + var args = androidSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender).toHaveBeenCalled(); + args = iosSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + it("can send iOS notifications", (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); + oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; + + oneSignalPushAdapter.sendToAPNS({'data':{ + 'badge': 1, + 'alert': "Example content", + 'sound': "Example sound", + 'content-available': 1, + 'misc-data': 'Example Data' + }},[{'deviceToken':'iosToken1'},{'deviceToken':'iosToken2'}]) + + expect(sendToOneSignal).toHaveBeenCalled(); + var args = sendToOneSignal.calls.first().args; + expect(args[0]).toEqual({ + 'ios_badgeType':'SetTo', + 'ios_badgeCount':1, + 'contents': { 'en':'Example content'}, + 'ios_sound': 'Example sound', + 'content_available':true, + 'data':{'misc-data':'Example Data'}, + 'include_ios_tokens':['iosToken1','iosToken2'] + }) + done(); + }); + + it("can send Android notifications", (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); + oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; + + oneSignalPushAdapter.sendToGCM({'data':{ + 'title': 'Example title', + 'alert': 'Example content', + 'misc-data': 'Example Data' + }},[{'deviceToken':'androidToken1'},{'deviceToken':'androidToken2'}]) + + expect(sendToOneSignal).toHaveBeenCalled(); + var args = sendToOneSignal.calls.first().args; + expect(args[0]).toEqual({ + 'contents': { 'en':'Example content'}, + 'title': {'en':'Example title'}, + 'data':{'misc-data':'Example Data'}, + 'include_android_reg_ids': ['androidToken1','androidToken2'] + }) + done(); + }); + + it("can post the correct data", (done) => { + var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" + }; + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); + + var write = jasmine.createSpy('write'); + oneSignalPushAdapter.https = { + 'request': function(a,b) { + return { + 'end':function(){}, + 'on':function(a,b){}, + 'write':write + } + } + }; + + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + oneSignalPushAdapter.send({'data':{ + 'title': 'Example title', + 'alert': 'Example content', + 'content-available':1, + 'misc-data': 'Example Data' + }}, installations); + + expect(write).toHaveBeenCalled(); + + // iOS + args = write.calls.first().args; + expect(args[0]).toEqual(JSON.stringify({ + 'contents': { 'en':'Example content'}, + 'content_available':true, + 'data':{'title':'Example title','misc-data':'Example Data'}, + 'include_ios_tokens':['iosToken'], + 'app_id':'APP ID' + })); + + // Android + args = write.calls.mostRecent().args; + expect(args[0]).toEqual(JSON.stringify({ + 'contents': { 'en':'Example content'}, + 'title': {'en':'Example title'}, + 'data':{"content-available":1,'misc-data':'Example Data'}, + 'include_android_reg_ids':['androidToken'], + 'app_id':'APP ID' + })); + + done(); + }); + + function makeDevice(deviceToken, appIdentifier) { + return { + deviceToken: deviceToken + }; + } + +}); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 91bb9a23b4..cef6871ee7 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -133,26 +133,6 @@ describe('Installations', () => { }); }); - it('fails for android with device token', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device, - 'deviceToken': t, - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - fail('Should not have been able to create an Installation.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(114); - done(); - }); - }); - it('fails for android with missing type', (done) => { var installId = '12345678-abcd-abcd-abcd-123456789abc'; var input = { diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js new file mode 100644 index 0000000000..59a660f9ee --- /dev/null +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -0,0 +1,230 @@ +"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; +var deepcopy = require('deepcopy'); + +function OneSignalPushAdapter(pushConfig) { + this.https = require('https'); + + this.validPushTypes = ['ios', 'android']; + this.senderMap = {}; + + pushConfig = pushConfig || {}; + this.OneSignalConfig = {}; + this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; + this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; + + this.senderMap['ios'] = this.sendToAPNS.bind(this); + this.senderMap['android'] = this.sendToGCM.bind(this); +} + +/** + * Get an array of valid push types. + * @returns {Array} An array of valid push types + */ +OneSignalPushAdapter.prototype.getValidPushTypes = function() { + return this.validPushTypes; +} + +OneSignalPushAdapter.prototype.send = function(data, installations) { + console.log("Sending notification to "+installations.length+" devices.") + 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]; + + if(devices.length > 0) { + sendPromises.push(sender(data, devices)); + } + } + return Parse.Promise.when(sendPromises); +} + +OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { + + data= deepcopy(data['data']); + + var post = {}; + if(data['badge']) { + if(data['badge'] == "Increment") { + post['ios_badgeType'] = 'Increase'; + post['ios_badgeCount'] = 1; + } else { + post['ios_badgeType'] = 'SetTo'; + post['ios_badgeCount'] = data['badge']; + } + delete data['badge']; + } + if(data['alert']) { + post['contents'] = {en: data['alert']}; + delete data['alert']; + } + if(data['sound']) { + post['ios_sound'] = data['sound']; + delete data['sound']; + } + if(data['content-available'] == 1) { + post['content_available'] = true; + delete data['content-available']; + } + post['data'] = data; + + let promise = new Parse.Promise(); + + var chunk = 2000 // OneSignal can process 2000 devices at a time + var tokenlength=tokens.length; + var offset = 0 + // handle onesignal response. Start next batch if there's not an error. + let handleResponse = function(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSignal Error"); + } + + if(offset >= tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this) + + this.sendNext = function() { + post['include_ios_tokens'] = []; + tokens.slice(offset,offset+chunk).forEach(function(i) { + post['include_ios_tokens'].push(i['deviceToken']) + }) + offset+=chunk; + this.sendToOneSignal(post, handleResponse); + }.bind(this) + + this.sendNext() + + return promise; +} + +OneSignalPushAdapter.prototype.sendToGCM = function(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']; + } + if(data['uri']) { + post['url'] = data['uri']; + } + + post['data'] = data; + + let promise = new Parse.Promise(); + + var chunk = 2000 // OneSignal can process 2000 devices at a time + var tokenlength=tokens.length; + var offset = 0 + // handle onesignal response. Start next batch if there's not an error. + let handleResponse = function(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSIgnal Error"); + } + + if(offset >= tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this); + + this.sendNext = function() { + post['include_android_reg_ids'] = []; + tokens.slice(offset,offset+chunk).forEach(function(i) { + post['include_android_reg_ids'].push(i['deviceToken']) + }) + offset+=chunk; + this.sendToOneSignal(post, handleResponse); + }.bind(this) + + + this.sendNext(); + return promise; +} + + +OneSignalPushAdapter.prototype.sendToOneSignal = function(data, cb) { + let headers = { + "Content-Type": "application/json", + "Authorization": "Basic "+this.OneSignalConfig['apiKey'] + }; + let options = { + host: "onesignal.com", + port: 443, + path: "/api/v1/notifications", + method: "POST", + headers: headers + }; + data['app_id'] = this.OneSignalConfig['appId']; + + let request = this.https.request(options, function(res) { + if(res.statusCode < 299) { + cb(true); + } else { + console.log('OneSignal Error'); + res.on('data', function(chunk) { + console.log(chunk.toString()) + }); + cb(false) + } + }); + request.on('error', function(e) { + console.log("Error connecting to OneSignal") + console.log(e); + cb(false); + }); + request.write(JSON.stringify(data)) + request.end(); +} +/**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 + }); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceMap; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + OneSignalPushAdapter.classifyInstallation = classifyInstallation; +} +module.exports = OneSignalPushAdapter; diff --git a/src/RestWrite.js b/src/RestWrite.js index 2a2b0ed2ac..b6c8f12681 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -485,11 +485,6 @@ RestWrite.prototype.handleInstallation = function() { this.data.installationId = this.data.installationId.toLowerCase(); } - if (this.data.deviceToken && this.data.deviceType == 'android') { - throw new Parse.Error(114, - 'deviceToken may not be set for deviceType android'); - } - var promise = Promise.resolve(); if (this.query && this.query.objectId) {