diff --git a/spec/FCM.spec.js b/spec/FCM.spec.js new file mode 100644 index 00000000..4b481443 --- /dev/null +++ b/spec/FCM.spec.js @@ -0,0 +1,475 @@ +const FCM = require('../src/FCM').default; +const path = require('path'); + +describe('FCM', () => { + it('can initialize', () => { + const args = { + firebaseServiceAccount: path.join( + __dirname, + '..', + 'spec', + 'support', + 'fakeServiceAccount.json', + ), + }; + const fcm = new FCM(args); + expect(fcm).toBeDefined(); + }); + + it('can use a raw FCM payload', () => { + // If the payload is wrapped inside a key named 'rawPayload', a user can use the raw FCM payload structure + // See: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages + // And: https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.multicastmessage.md#multicastmessage_interface + + const requestData = { + rawPayload: { + data: { + alert: 'alert', + }, + notification: { + title: 'I am a title', + body: 'I am a body', + }, + android: { + priority: 'high', + }, + apns: { + headers: { + 'apns-priority': '5', + }, + payload: { + aps: { + contentAvailable: true, + }, + }, + }, + }, + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + requestData, + pushId, + timeStamp, + ['testToken'], + 'android', + ); + + expect(payload.data.data).toEqual(requestData.rawPayload.data); + expect(payload.data.notification).toEqual( + requestData.rawPayload.notification, + ); + expect(payload.data.android).toEqual(requestData.rawPayload.android); + expect(payload.data.apns).toEqual(requestData.rawPayload.apns); + expect(payload.data.tokens).toEqual(['testToken']); + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + }); + + it('can slice devices', () => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; + + var chunkDevices = FCM.sliceDevices(devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)], + ]); + }); + + describe('GCM payloads can be converted to compatible FCMv1 payloads', () => { + it('can generate GCM Payload without expiration time', () => { + // To maintain backwards compatibility with GCM payload format + // See corresponding test with same test label in GCM.spec.js + + const requestData = { + data: { + alert: 'alert', + }, + notification: { + title: 'I am a title', + body: 'I am a body', + }, + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + requestData, + pushId, + timeStamp, + ['testToken'], + 'android', + ); + + const fcmPayload = payload.data; + + expect(fcmPayload.tokens).toEqual(['testToken']); + expect(fcmPayload.android.priority).toEqual('high'); + expect(fcmPayload.android.ttl).toEqual(undefined); + expect(fcmPayload.android.notification).toEqual(requestData.notification); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + + const dataFromUser = fcmPayload.android.data; + expect(dataFromUser).toEqual(requestData.data); + }); + + it('can generate GCM Payload with valid expiration time', () => { + // To maintain backwards compatibility with GCM payload format + // See corresponding test with same test label in GCM.spec.js + + // We set expiration_time directly into requestData + // The GCM module adds the key in send() instead and the value gets passed as a param to the payload generation + // Has the same effect in the end + + const expirationTime = 1454538922113; + + const requestData = { + expiration_time: expirationTime, + data: { + alert: 'alert', + }, + notification: { + title: 'I am a title', + body: 'I am a body', + }, + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + requestData, + pushId, + timeStamp, + ['testToken'], + 'android', + ); + + const fcmPayload = payload.data; + + expect(fcmPayload.tokens).toEqual(['testToken']); + expect(fcmPayload.android.priority).toEqual('high'); + expect(fcmPayload.android.ttl).toEqual( + Math.floor((expirationTime - timeStamp) / 1000), + ); + expect(fcmPayload.android.notification).toEqual(requestData.notification); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + + const dataFromUser = fcmPayload.android.data; + expect(dataFromUser).toEqual(requestData.data); + }); + + it('can generate GCM Payload with too early expiration time', () => { + // To maintain backwards compatibility with GCM payload format + // See corresponding test with same test label in GCM.spec.js + + const expirationTime = 1454538822112; + + const requestData = { + expiration_time: expirationTime, + data: { + alert: 'alert', + }, + notification: { + title: 'I am a title', + body: 'I am a body', + }, + }; + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + requestData, + pushId, + timeStamp, + ['testToken'], + 'android', + ); + const fcmPayload = payload.data; + expect(fcmPayload.tokens).toEqual(['testToken']); + expect(fcmPayload.android.priority).toEqual('high'); + expect(fcmPayload.android.ttl).toEqual(0); + expect(fcmPayload.android.notification).toEqual(requestData.notification); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + + const dataFromUser = fcmPayload.android.data; + expect(dataFromUser).toEqual(requestData.data); + }); + + it('can generate GCM Payload with too late expiration time', () => { + const expirationTime = 2454538822113; + + const requestData = { + expiration_time: expirationTime, + data: { + alert: 'alert', + }, + notification: { + title: 'I am a title', + body: 'I am a body', + }, + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + requestData, + pushId, + timeStamp, + ['testToken'], + 'android', + ); + const fcmPayload = payload.data; + + expect(fcmPayload.tokens).toEqual(['testToken']); + expect(fcmPayload.android.priority).toEqual('high'); + + // Four weeks in seconds + expect(fcmPayload.android.ttl).toEqual(4 * 7 * 24 * 60 * 60); + expect(fcmPayload.android.notification).toEqual(requestData.notification); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + + const dataFromUser = fcmPayload.android.data; + expect(dataFromUser).toEqual(requestData.data); + }); + }); + + // We do not need to explicitly set priority to 10 under headers as is done in APNS.js + // FCM backend sets apns-priority to 10 and apns-expiration to 30 days by default if not set. + // + // We also do not need to pass APNS headers like expiration_time, collapse_id etc to FCM.generatePayload() as is done for APNS._generateNotification() for generating the payload. + // APNS headers get set if present in the payload data. + describe('APNS payloads can be converted to compatible FCMv1 payloads', () => { + it('can generate APNS notification', () => { + // To maintain backwards compatibility with APNS payload format + // See corresponding test with same test label in APNS.spec.js + + let expirationTime = 1454571491354; + let collapseId = 'collapseIdentifier'; + let pushType = 'alert'; + let priority = 5; + + let data = { + expiration_time: expirationTime, + collapse_id: collapseId, + push_type: pushType, + priority: priority, + alert: 'alert', + title: 'title', + badge: 100, + sound: 'test', + 'content-available': 1, + 'mutable-content': 1, + targetContentIdentifier: 'window1', + interruptionLevel: 'passive', + category: 'INVITE_CATEGORY', + threadId: 'a-thread-id', + key: 'value', + keyAgain: 'valueAgain', + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + data, + pushId, + timeStamp, + ['tokenTest'], + 'apple', + ); + const fcmPayload = payload.data; + + expect(fcmPayload.apns.payload.aps.alert).toEqual({ + body: 'alert', + title: 'title', + }); + expect(fcmPayload.apns.payload.aps.badge).toEqual(data.badge); + expect(fcmPayload.apns.payload.aps.sound).toEqual(data.sound); + expect(fcmPayload.apns.payload.aps['content-available']).toEqual(1); + expect(fcmPayload.apns.payload.aps['mutable-content']).toEqual(1); + expect(fcmPayload.apns.payload.aps['target-content-id']).toEqual( + 'window1', + ); + expect(fcmPayload.apns.payload.aps['interruption-level']).toEqual( + 'passive', + ); + expect(fcmPayload.apns.payload.aps.category).toEqual(data.category); + expect(fcmPayload.apns.payload.aps['thread-id']).toEqual(data.threadId); + + // Custom keys should be outside aps but inside payload according to FCMv1 APNS spec + expect(fcmPayload.apns.payload).toEqual( + jasmine.objectContaining({ + key: 'value', + keyAgain: 'valueAgain', + }), + ); + expect(fcmPayload.apns.headers['apns-expiration']).toEqual( + Math.round(expirationTime / 1000), + ); + expect(fcmPayload.apns.headers['apns-collapse-id']).toEqual(collapseId); + expect(fcmPayload.apns.headers['apns-push-type']).toEqual(pushType); + expect(fcmPayload.apns.headers['apns-priority']).toEqual(priority); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + }); + + it('sets push type to alert if not defined explicitly', () => { + let data = { + alert: 'alert', + title: 'title', + badge: 100, + sound: 'test', + 'content-available': 1, + 'mutable-content': 1, + category: 'INVITE_CATEGORY', + threadId: 'a-thread-id', + key: 'value', + keyAgain: 'valueAgain', + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + data, + pushId, + timeStamp, + ['tokenTest'], + 'apple', + ); + const fcmPayload = payload.data; + + expect(fcmPayload.apns.headers['apns-push-type']).toEqual('alert'); + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + }); + + it('can generate APNS notification from raw data', () => { + let expirationTime = 1454571491354; + let collapseId = 'collapseIdentifier'; + let pushType = 'background'; + let priority = 5; + let data = { + expiration_time: expirationTime, + collapse_id: collapseId, + push_type: pushType, + priority: priority, + aps: { + alert: { + 'loc-key': 'GAME_PLAY_REQUEST_FORMAT', + 'loc-args': ['Jenna', 'Frank'], + }, + badge: 100, + sound: 'test', + 'thread-id': 'a-thread-id', + }, + key: 'value', + keyAgain: 'valueAgain', + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + data, + pushId, + timeStamp, + ['tokenTest'], + 'apple', + ); + const fcmPayload = payload.data; + + expect(fcmPayload.apns.headers['apns-expiration']).toEqual( + Math.round(expirationTime / 1000), + ); + expect(fcmPayload.apns.headers['apns-collapse-id']).toEqual(collapseId); + expect(fcmPayload.apns.headers['apns-push-type']).toEqual(pushType); + expect(fcmPayload.apns.headers['apns-priority']).toEqual(priority); + expect(fcmPayload.apns.payload.aps.alert).toEqual({ + 'loc-key': 'GAME_PLAY_REQUEST_FORMAT', + 'loc-args': ['Jenna', 'Frank'], + }); + expect(fcmPayload.apns.payload.aps.badge).toEqual(100); + expect(fcmPayload.apns.payload.aps.sound).toEqual('test'); + expect(fcmPayload.apns.payload.aps['thread-id']).toEqual('a-thread-id'); + expect(fcmPayload.apns.payload.key).toEqual('value'); + expect(fcmPayload.apns.payload.keyAgain).toEqual('valueAgain'); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + }); + + it('can generate an APNS notification with headers in data', () => { + // See 'can send APNS notification headers in data' in APNS.spec.js + // Not mocking sends currently, only payload generation + + let expirationTime = 1454571491354; + let collapseId = 'collapseIdentifier'; + let pushType = 'alert'; // or background + + let data = { + expiration_time: expirationTime, + data: { + alert: 'alert', + collapse_id: collapseId, + push_type: pushType, + priority: 6, + }, + }; + + const pushId = 'pushId'; + const timeStamp = 1454538822113; + const timeStampISOStr = new Date(timeStamp).toISOString(); + + const payload = FCM.generateFCMPayload( + data, + pushId, + timeStamp, + ['tokenTest'], + 'apple', + ); + + const fcmPayload = payload.data; + + expect(fcmPayload.apns.payload.aps.alert).toEqual({ body: 'alert' }); + expect(fcmPayload.apns.headers['apns-expiration']).toEqual( + Math.round(expirationTime / 1000), + ); + expect(fcmPayload.apns.headers['apns-collapse-id']).toEqual(collapseId); + expect(fcmPayload.apns.headers['apns-push-type']).toEqual(pushType); + expect(fcmPayload.apns.headers['apns-priority']).toEqual(6); + + expect(payload.time).toEqual(timeStampISOStr); + expect(payload['push_id']).toEqual(pushId); + }); + }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken, + }; + } +}); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 3c6f4f97..79c63d72 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -4,6 +4,8 @@ var randomString = require('../src/PushAdapterUtils').randomString; var APNS = require('../src/APNS').default; var GCM = require('../src/GCM').default; var MockAPNProvider = require('./MockAPNProvider'); +var FCM = require('../src/FCM').default +const path = require('path'); describe('ParsePushAdapter', () => { @@ -56,6 +58,72 @@ describe('ParsePushAdapter', () => { done(); }); + it("can be initialized with FCM for android and ios", (done) => { + var pushConfig = { + android: { + firebaseServiceAccount: path.join(__dirname, '..', 'spec', 'support', 'fakeServiceAccount.json') + }, + ios: { + firebaseServiceAccount: path.join(__dirname, '..', 'spec', 'support', 'fakeServiceAccount.json') + }, + }; + + var parsePushAdapter = new ParsePushAdapter(pushConfig); + var iosSender = parsePushAdapter.senderMap["ios"]; + expect(iosSender instanceof FCM).toBe(true); + var androidSender = parsePushAdapter.senderMap["android"]; + expect(androidSender instanceof FCM).toBe(true); + done(); + }); + + it("can be initialized with FCM for android and APNS for apple", (done) => { + var pushConfig = { + android: { + firebaseServiceAccount: path.join(__dirname, '..', 'spec', 'support', 'fakeServiceAccount.json') + }, + ios: [ + { + cert: new Buffer("testCert"), + key: new Buffer("testKey"), + production: true, + topic: "topic", + }, + { + cert: new Buffer("testCert"), + key: new Buffer("testKey"), + production: false, + topic: "topicAgain", + }, + ], + }; + + var parsePushAdapter = new ParsePushAdapter(pushConfig); + var iosSender = parsePushAdapter.senderMap["ios"]; + expect(iosSender instanceof APNS).toBe(true); + var androidSender = parsePushAdapter.senderMap["android"]; + expect(androidSender instanceof FCM).toBe(true); + done(); + }); + + it("can be initialized with FCM for apple and GCM for android", (done) => { + var pushConfig = { + android: { + senderId: "senderId", + apiKey: "apiKey", + }, + ios: { + firebaseServiceAccount: path.join(__dirname, '..', 'spec', 'support', 'fakeServiceAccount.json') + }, + }; + + var parsePushAdapter = new ParsePushAdapter(pushConfig); + var iosSender = parsePushAdapter.senderMap["ios"]; + expect(iosSender instanceof FCM).toBe(true); + 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 = { diff --git a/spec/support/fakeServiceAccount.json b/spec/support/fakeServiceAccount.json new file mode 100644 index 00000000..348342c3 --- /dev/null +++ b/spec/support/fakeServiceAccount.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "example-xxxx", + "private_key_id": "xxxx", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n", + "client_email": "test@example.com", + "client_id": "1", + "auth_uri": "https://example.com", + "token_uri": "https://example.com", + "auth_provider_x509_cert_url": "https://example.com", + "client_x509_cert_url": "https://example.com", + "universe_domain": "example.com" +} diff --git a/src/FCM.js b/src/FCM.js index 45eba22f..e6bd52ec 100644 --- a/src/FCM.js +++ b/src/FCM.js @@ -1,4 +1,4 @@ -"use strict"; +'use strict'; import Parse from 'parse'; import log from 'npmlog'; @@ -9,21 +9,30 @@ import { randomString } from './PushAdapterUtils'; const LOG_PREFIX = 'parse-server-push-adapter FCM'; const FCMRegistrationTokensMax = 500; const FCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // FCM allows a max of 4 weeks - -export default function FCM(args) { +const apnsIntegerDataKeys = [ + 'badge', + 'content-available', + 'mutable-content', + 'priority', + 'expiration_time', +]; + +export default function FCM(args, pushType) { if (typeof args !== 'object' || !args.firebaseServiceAccount) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'FCM Configuration is invalid'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'FCM Configuration is invalid', + ); } let app; if (getApps().length === 0) { - app = initializeApp({credential: cert(args.firebaseServiceAccount)}); - } - else { + app = initializeApp({ credential: cert(args.firebaseServiceAccount) }); + } else { app = getApp(); } this.sender = getMessaging(app); + this.pushType = pushType; // Push type is only used to remain backwards compatible with APNS and GCM } FCM.FCMRegistrationTokensMax = FCMRegistrationTokensMax; @@ -35,7 +44,7 @@ FCM.FCMRegistrationTokensMax = FCMRegistrationTokensMax; * @returns {Object} Array of resolved promises */ -FCM.prototype.send = function(data, devices) { +FCM.prototype.send = function (data, devices) { if (!data || !devices || !Array.isArray(devices)) { log.warn(LOG_PREFIX, 'invalid push payload'); return; @@ -45,7 +54,7 @@ FCM.prototype.send = function(data, devices) { // chunk if necessary const slices = sliceDevices(devices, FCM.FCMRegistrationTokensMax); - const sendToDeviceSlice = (deviceSlice) => { + const sendToDeviceSlice = (deviceSlice, pushType) => { const pushId = randomString(10); const timestamp = Date.now(); @@ -56,11 +65,19 @@ FCM.prototype.send = function(data, devices) { }, {}); const deviceTokens = Object.keys(devicesMap); - const fcmPayload = generateFCMPayload(data, pushId, timestamp, deviceTokens); + + const fcmPayload = generateFCMPayload( + data, + pushId, + timestamp, + deviceTokens, + pushType, + ); const length = deviceTokens.length; log.info(LOG_PREFIX, `sending push to ${length} devices`); - return this.sender.sendEachForMulticast(fcmPayload.data) + return this.sender + .sendEachForMulticast(fcmPayload.data) .then((response) => { const promises = []; const failedTokens = []; @@ -69,32 +86,219 @@ FCM.prototype.send = function(data, devices) { response.responses.forEach((resp, idx) => { if (resp.success) { successfulTokens.push(deviceTokens[idx]); - promises.push(createSuccessfulPromise(deviceTokens[idx], devicesMap[deviceTokens[idx]].deviceType)); + promises.push( + createSuccessfulPromise( + deviceTokens[idx], + devicesMap[deviceTokens[idx]].deviceType, + ), + ); } else { failedTokens.push(deviceTokens[idx]); - promises.push(createErrorPromise(deviceTokens[idx], devicesMap[deviceTokens[idx]].deviceType, resp.error)); - log.error(LOG_PREFIX, `failed to send to ${deviceTokens[idx]} with error: ${JSON.stringify(resp.error)}`); + promises.push( + createErrorPromise( + deviceTokens[idx], + devicesMap[deviceTokens[idx]].deviceType, + resp.error, + ), + ); + log.error( + LOG_PREFIX, + `failed to send to ${deviceTokens[idx]} with error: ${JSON.stringify(resp.error)}`, + ); } }); if (failedTokens.length) { - log.error(LOG_PREFIX, `tokens with failed pushes: ${JSON.stringify(failedTokens)}`); + log.error( + LOG_PREFIX, + `tokens with failed pushes: ${JSON.stringify(failedTokens)}`, + ); } if (successfulTokens.length) { - log.verbose(LOG_PREFIX, `tokens with successful pushes: ${JSON.stringify(successfulTokens)}`); + log.verbose( + LOG_PREFIX, + `tokens with successful pushes: ${JSON.stringify(successfulTokens)}`, + ); } return Promise.all(promises); }); }; - const allPromises = Promise.all(slices.map(sendToDeviceSlice)) - .catch((err) => { - log.error(LOG_PREFIX, `error sending push: ${err}`); - }); + const allPromises = Promise.all( + slices.map((slice) => sendToDeviceSlice(slice, this.pushType)), + ).catch((err) => { + log.error(LOG_PREFIX, `error sending push: ${err}`); + }); return allPromises; +}; + +function _APNSToFCMPayload(requestData) { + let coreData = requestData; + + if (requestData.hasOwnProperty('data')) { + coreData = requestData.data; + } + + let expirationTime = + requestData['expiration_time'] || coreData['expiration_time']; + let collapseId = requestData['collapse_id'] || coreData['collapse_id']; + let pushType = requestData['push_type'] || coreData['push_type']; + let priority = requestData['priority'] || coreData['priority']; + + let apnsPayload = { apns: { payload: { aps: {} } } }; + let headers = {}; + + // Set to alert by default if not set explicitly + headers['apns-push-type'] = 'alert'; + + if (expirationTime) { + headers['apns-expiration'] = Math.round(expirationTime / 1000); + } + + if (collapseId) { + headers['apns-collapse-id'] = collapseId; + } + if (pushType) { + headers['apns-push-type'] = pushType; + } + if (priority) { + headers['apns-priority'] = priority; + } + + if (Object.keys(headers).length > 0) { + apnsPayload.apns.headers = headers; + } + + for (let key in coreData) { + switch (key) { + case 'aps': + apnsPayload['apns']['payload']['aps'] = coreData.aps; + break; + case 'alert': + if (!apnsPayload['apns']['payload']['aps'].hasOwnProperty('alert')) { + apnsPayload['apns']['payload']['aps']['alert'] = {}; + } + // In APNS.js we set a body with the same value as alert in requestData. + // See L200 in APNS.spec.js + apnsPayload['apns']['payload']['aps']['alert']['body'] = coreData.alert; + break; + case 'title': + // Ensure the alert object exists before trying to assign the title + if (!apnsPayload['apns']['payload']['aps'].hasOwnProperty('alert')) { + apnsPayload['apns']['payload']['aps']['alert'] = {}; + } + apnsPayload['apns']['payload']['aps']['alert']['title'] = + coreData.title; + break; + case 'badge': + apnsPayload['apns']['payload']['aps']['badge'] = coreData.badge; + break; + case 'sound': + apnsPayload['apns']['payload']['aps']['sound'] = coreData.sound; + break; + case 'content-available': + apnsPayload['apns']['payload']['aps']['content-available'] = + coreData['content-available']; + break; + case 'mutable-content': + apnsPayload['apns']['payload']['aps']['mutable-content'] = + coreData['mutable-content']; + break; + case 'targetContentIdentifier': + apnsPayload['apns']['payload']['aps']['target-content-id'] = + coreData.targetContentIdentifier; + break; + case 'interruptionLevel': + apnsPayload['apns']['payload']['aps']['interruption-level'] = + coreData.interruptionLevel; + break; + case 'category': + apnsPayload['apns']['payload']['aps']['category'] = coreData.category; + break; + case 'threadId': + apnsPayload['apns']['payload']['aps']['thread-id'] = coreData.threadId; + break; + case 'expiration_time': // Exclude header-related fields as these are set above + break; + case 'collapse_id': + break; + case 'push_type': + break; + case 'priority': + break; + default: + apnsPayload['apns']['payload'][key] = coreData[key]; // Custom keys should be outside aps + break; + } + } + return apnsPayload; +} + +function _GCMToFCMPayload(requestData, timeStamp) { + const androidPayload = { + android: { + priority: 'high', + }, + }; + + if (requestData.hasOwnProperty('notification')) { + androidPayload.android.notification = requestData.notification; + } + + if (requestData.hasOwnProperty('data')) { + // FCM gives an error on send if we have apns keys that should have integer values + for (const key of apnsIntegerDataKeys) { + if (requestData.data.hasOwnProperty(key)) { + delete requestData.data[key] + } + } + androidPayload.android.data = requestData.data; + } + + if (requestData['expiration_time']) { + const expirationTime = requestData['expiration_time']; + // Convert to seconds + let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); + if (timeToLive < 0) { + timeToLive = 0; + } + if (timeToLive >= FCMTimeToLiveMax) { + timeToLive = FCMTimeToLiveMax; + } + + androidPayload.android.ttl = timeToLive; + } + + return androidPayload; +} + +/** + * Converts payloads used by APNS or GCM into a FCMv1-compatible payload. + * Purpose is to remain backwards-compatible will payloads used in the APNS.js and GCM.js modules. + * If the key rawPayload is present in the requestData, a raw payload will be used. Otherwise, conversion is done. + * @param {Object} requestData The request body + * @param {String} pushType Either apple or android. + * @param {Number} timeStamp Used during GCM payload conversion for ttl + * @returns {Object} A FCMv1-compatible payload. + */ +function payloadConverter(requestData, pushType, timeStamp) { + if (requestData.hasOwnProperty('rawPayload')) { + return requestData.rawPayload; + } + + if (pushType === 'apple') { + return _APNSToFCMPayload(requestData); + } else if (pushType === 'android') { + return _GCMToFCMPayload(requestData, timeStamp); + } else { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Unsupported push type, apple or android only.', + ); + } } /** @@ -102,56 +306,30 @@ FCM.prototype.send = function(data, devices) { * @param {Object} requestData The request body * @param {String} pushId A random string * @param {Number} timeStamp A number in milliseconds since the Unix Epoch + * @param {Array.} deviceTokens An array of deviceTokens + * @param {String} pushType Either apple or android * @returns {Object} A payload for FCM */ -function generateFCMPayload(requestData, pushId, timeStamp, deviceTokens) { +function generateFCMPayload( + requestData, + pushId, + timeStamp, + deviceTokens, + pushType, +) { delete requestData['where']; const payloadToUse = { - data: {}, - push_id: pushId, - time: new Date(timeStamp).toISOString() + data: {}, + push_id: pushId, + time: new Date(timeStamp).toISOString(), }; - // Use rawPayload instead of the GCM implementation if it exists - if (requestData.hasOwnProperty('rawPayload')) { - payloadToUse.data = { - ...requestData.rawPayload, - tokens: deviceTokens - }; - } else { - // Android payload according to GCM implementation - const androidPayload = { - android: { - priority: 'high' - }, - tokens: deviceTokens - }; - - if (requestData.hasOwnProperty('notification')) { - androidPayload.notification = requestData.notification; - } - - if (requestData.hasOwnProperty('data')) { - androidPayload.data = requestData.data; - } - - if (requestData['expiration_time']) { - const expirationTime = requestData['expiration_time']; - // Convert to seconds - let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); - if (timeToLive < 0) { - timeToLive = 0; - } - if (timeToLive >= FCMTimeToLiveMax) { - timeToLive = FCMTimeToLiveMax; - } - - androidPayload.android.ttl = timeToLive; - } - - payloadToUse.data = androidPayload; - } + const fcmPayload = payloadConverter(requestData, pushType, timeStamp); + payloadToUse.data = { + ...fcmPayload, + tokens: deviceTokens, + }; return payloadToUse; } @@ -182,9 +360,9 @@ function createErrorPromise(token, deviceType, errorMessage) { transmitted: false, device: { deviceToken: token, - deviceType: deviceType + deviceType: deviceType, }, - response: { error: errorMessage } + response: { error: errorMessage }, }); } @@ -199,12 +377,11 @@ function createSuccessfulPromise(token, deviceType) { transmitted: true, device: { deviceToken: token, - deviceType: deviceType - } + deviceType: deviceType, + }, }); } - FCM.generateFCMPayload = generateFCMPayload; /* istanbul ignore else */ diff --git a/src/ParsePushAdapter.js b/src/ParsePushAdapter.js index 00a29246..446fd856 100644 --- a/src/ParsePushAdapter.js +++ b/src/ParsePushAdapter.js @@ -32,7 +32,7 @@ export default class ParsePushAdapter { case 'tvos': case 'osx': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { - this.senderMap[pushType] = new FCM(pushConfig[pushType]); + this.senderMap[pushType] = new FCM(pushConfig[pushType], 'apple'); } else { this.senderMap[pushType] = new APNS(pushConfig[pushType]); } @@ -40,7 +40,7 @@ export default class ParsePushAdapter { case 'android': case 'fcm': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { - this.senderMap[pushType] = new FCM(pushConfig[pushType]); + this.senderMap[pushType] = new FCM(pushConfig[pushType], 'android'); } else { this.senderMap[pushType] = new GCM(pushConfig[pushType]); }