From 06c89bac9513dfb3ac5ac4b6914de99fbab29bb0 Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Wed, 4 Mar 2026 18:12:50 +0530 Subject: [PATCH 1/2] fix FCM Push notification payload structure difference from firebase recommanded way --- src/core/components/push_payload.ts | 126 ++++++++-- test/unit/notifications_payload.test.ts | 298 +++++++++++++++++++++++- 2 files changed, 391 insertions(+), 33 deletions(-) diff --git a/src/core/components/push_payload.ts b/src/core/components/push_payload.ts index 20e8ffa17..27e70ca2e 100644 --- a/src/core/components/push_payload.ts +++ b/src/core/components/push_payload.ts @@ -177,16 +177,9 @@ type FCMPayload = { body?: string; /** - * Name of the icon file from resource bundle which should be shown on notification. + * URL of an image to be displayed in the notification. */ - icon?: string; - - /** - * Name of the file from resource bundle which should be played when notification received. - */ - sound?: string; - - tag?: string; + image?: string; }; /** @@ -194,7 +187,41 @@ type FCMPayload = { * * Silent notification configuration. */ - data?: { notification?: FCMPayload['notification'] }; + data?: { notification?: FCMPayload['notification'] } & Record; + + /** + * Android-specific options for notification delivery. + */ + android?: { + notification?: { + /** + * Name of the icon file from resource bundle which should be shown on notification. + */ + icon?: string; + + /** + * Name of the file from resource bundle which should be played when notification received. + */ + sound?: string; + + tag?: string; + }; + } & Record; + + /** + * APNS-specific options for notification delivery. + */ + apns?: Record; + + /** + * Web Push-specific options for notification delivery. + */ + webpush?: Record; + + /** + * FCM service level options. + */ + fcm_options?: Record; }; // endregion // endregion @@ -700,7 +727,7 @@ export class FCMNotificationPayload extends BaseNotificationPayload { set sound(value) { if (!value || !value.length) return; - this.payload.notification!.sound = value; + this.androidNotification.sound = value; this._sound = value; } @@ -716,12 +743,12 @@ export class FCMNotificationPayload extends BaseNotificationPayload { /** * Update notification icon. * - * @param value - Name of the icon file which should be shown on notification. + * @param value - Name of the icon file set for `android.notification.icon`. */ set icon(value) { if (!value || !value.length) return; - this.payload.notification!.icon = value; + this.androidNotification.icon = value; this._icon = value; } @@ -737,12 +764,12 @@ export class FCMNotificationPayload extends BaseNotificationPayload { /** * Update notifications grouping tag. * - * @param value - String which will be used to group similar notifications in notification center. + * @param value - String set for `android.notification.tag` to group similar notifications. */ set tag(value) { if (!value || !value.length) return; - this.payload.notification!.tag = value; + this.androidNotification.tag = value; this._tag = value; } @@ -767,6 +794,48 @@ export class FCMNotificationPayload extends BaseNotificationPayload { this.payload.data = {}; } + /** + * Retrieve Android notification payload and initialize required structure. + * + * @returns Android specific notification payload. + */ + private get androidNotification() { + if (!this.payload.android) this.payload.android = {}; + if (!this.payload.android.notification) this.payload.android.notification = {}; + + return this.payload.android.notification; + } + + /** + * Move legacy Android-specific notification fields under `android.notification`. + * + * @param notification - Common FCM notification payload. + * @param android - Android-specific payload. + * + * @returns Updated Android payload. + */ + private moveLegacyAndroidNotificationFields( + notification: NonNullable, + android?: FCMPayload['android'], + ): FCMPayload['android'] { + const legacyFields = ['sound', 'icon', 'tag'] as const; + const notificationPayload = notification as Record; + let androidPayload = android; + + legacyFields.forEach((field) => { + const value = notificationPayload[field]; + if (typeof value !== 'string' || !value.length) return; + + if (!androidPayload) androidPayload = {}; + if (!androidPayload.notification) androidPayload.notification = {}; + + androidPayload.notification[field] = value; + delete notificationPayload[field]; + }); + + return androidPayload; + } + /** * Translate data object into PubNub push notification payload object. * @@ -775,22 +844,33 @@ export class FCMNotificationPayload extends BaseNotificationPayload { * @returns Preformatted push notification payload. */ public toObject(): FCMPayload | null { - let data = { ...this.payload.data }; - let notification = null; + const reservedTopLevelKeys = ['notification', 'data', 'android', 'apns', 'webpush', 'fcm_options'] as const; + let data = { ...(this.payload.data ?? {}) }; + const notification = { ...(this.payload.notification ?? {}) }; + let android = this.payload.android ? { ...this.payload.android } : undefined; const payload: FCMPayload = {}; + const { apns, webpush, fcm_options } = this.payload; + const additionalData = { ...this.payload } as Record; - // Check whether additional data has been passed outside 'data' object and put it into it if required. - if (Object.keys(this.payload).length > 2) { - const { notification: initialNotification, data: initialData, ...additionalData } = this.payload; + android = this.moveLegacyAndroidNotificationFields(notification, android); + reservedTopLevelKeys.forEach((key) => { + delete additionalData[key]; + }); + + // Check whether additional data has been passed outside 'data' object and put it into it if required. + if (Object.keys(additionalData).length) { data = { ...data, ...additionalData }; } - if (this._isSilent) data.notification = this.payload.notification; - else notification = this.payload.notification; + if (this._isSilent && Object.keys(notification).length) data.notification = notification; + else if (Object.keys(notification).length) payload.notification = notification; if (Object.keys(data).length) payload.data = data; - if (notification && Object.keys(notification).length) payload.notification = notification; + if (android && Object.keys(android).length) payload.android = android; + if (apns && Object.keys(apns).length) payload.apns = apns; + if (webpush && Object.keys(webpush).length) payload.webpush = webpush; + if (fcm_options && Object.keys(fcm_options).length) payload.fcm_options = fcm_options; return Object.keys(payload).length ? payload : null; } diff --git a/test/unit/notifications_payload.test.ts b/test/unit/notifications_payload.test.ts index 9c4a3baf4..66fad1b4b 100644 --- a/test/unit/notifications_payload.test.ts +++ b/test/unit/notifications_payload.test.ts @@ -77,8 +77,9 @@ describe('#notifications helper', () => { assert(apnsPayload); assert.equal(apnsPayload.aps.sound, expectedSound); assert(fcmPayload); - assert(fcmPayload.notification); - assert.equal(fcmPayload.notification.sound, expectedSound); + assert(fcmPayload.android); + assert(fcmPayload.android.notification); + assert.equal(fcmPayload.android.notification.sound, expectedSound); }); it('should set debug flag', () => { @@ -138,6 +139,126 @@ describe('#notifications helper', () => { builder.buildPayload(['apns2', 'fcm']); }); }); + + it('should provide payload for FCM only', () => { + let expectedTitle = PubNub.generateUUID(); + let expectedBody = PubNub.generateUUID(); + let expectedPayload = { + pn_fcm: { + notification: { title: expectedTitle, body: expectedBody }, + }, + }; + + let builder = PubNub.notificationPayload(expectedTitle, expectedBody); + + const result = builder.buildPayload(['fcm']); + assert.deepEqual(result, expectedPayload); + assert(!('pn_apns' in result)); + }); + + it('should generate FCM Android notification fields in final payload', () => { + let expectedTitle = PubNub.generateUUID(); + let expectedBody = PubNub.generateUUID(); + let expectedSound = PubNub.generateUUID(); + let expectedIcon = PubNub.generateUUID(); + let expectedTag = PubNub.generateUUID(); + let expectedPayload = { + pn_fcm: { + notification: { title: expectedTitle, body: expectedBody }, + android: { + notification: { sound: expectedSound, icon: expectedIcon, tag: expectedTag }, + }, + }, + }; + + let builder = PubNub.notificationPayload(expectedTitle, expectedBody); + builder.sound = expectedSound; + builder.fcm.icon = expectedIcon; + builder.fcm.tag = expectedTag; + + assert.deepEqual(builder.buildPayload(['fcm']), expectedPayload); + }); + + it('should preserve FCM platform blocks in final payload', () => { + let expectedTitle = PubNub.generateUUID(); + let expectedBody = PubNub.generateUUID(); + let expectedSound = PubNub.generateUUID(); + let expectedPayload = { + pn_fcm: { + notification: { title: expectedTitle, body: expectedBody }, + android: { + priority: 'HIGH', + notification: { sound: expectedSound }, + }, + apns: { + headers: { 'apns-priority': '10' }, + }, + webpush: { + headers: { Urgency: 'high' }, + }, + fcm_options: { + analytics_label: 'campaign-a', + }, + }, + }; + + let builder = PubNub.notificationPayload(expectedTitle, expectedBody); + builder.sound = expectedSound; + (builder.fcm.payload.android as Record).priority = 'HIGH'; + builder.fcm.payload.apns = { headers: { 'apns-priority': '10' } }; + builder.fcm.payload.webpush = { headers: { Urgency: 'high' } }; + builder.fcm.payload.fcm_options = { analytics_label: 'campaign-a' }; + + assert.deepEqual(builder.buildPayload(['fcm']), expectedPayload); + }); + + it('should generate silent FCM payload with Android notification fields', () => { + let expectedTitle = PubNub.generateUUID(); + let expectedBody = PubNub.generateUUID(); + let expectedSound = PubNub.generateUUID(); + let expectedPayload = { + pn_fcm: { + data: { + notification: { title: expectedTitle, body: expectedBody }, + }, + android: { + notification: { sound: expectedSound }, + }, + }, + }; + + let builder = PubNub.notificationPayload(expectedTitle, expectedBody); + builder.sound = expectedSound; + builder.fcm.silent = true; + + assert.deepEqual(builder.buildPayload(['fcm']), expectedPayload); + }); + + it('should migrate legacy FCM notification Android fields in final payload', () => { + let expectedTitle = PubNub.generateUUID(); + let expectedBody = PubNub.generateUUID(); + let expectedSound = PubNub.generateUUID(); + let expectedIcon = PubNub.generateUUID(); + let expectedTag = PubNub.generateUUID(); + let expectedPayload = { + pn_fcm: { + notification: { title: expectedTitle, body: expectedBody }, + android: { + notification: { sound: expectedSound, icon: expectedIcon, tag: expectedTag }, + }, + }, + }; + + let builder = PubNub.notificationPayload(expectedTitle, expectedBody); + // @ts-expect-error Intentional simulation of legacy notification fields. + builder.fcm.notification.sound = expectedSound; + // @ts-expect-error Intentional simulation of legacy notification fields. + builder.fcm.notification.icon = expectedIcon; + // @ts-expect-error Intentional simulation of legacy notification fields. + builder.fcm.notification.tag = expectedTag; + + assert.deepEqual(builder.buildPayload(['fcm']), expectedPayload); + }); }); describe('apns builder', () => { @@ -380,7 +501,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.sound = expectedSound; - assert.equal(platformPayloadStorage.notification.sound, expectedSound); + assert.equal(platformPayloadStorage.android.notification.sound, expectedSound); }); it("should not set 'sound' if value is empty", () => { @@ -389,7 +510,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.sound = expectedSound; - assert(!platformPayloadStorage.notification.sound); + assert(!platformPayloadStorage.android); }); it("should set 'icon'", () => { @@ -398,7 +519,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.icon = expectedIcon; - assert.equal(platformPayloadStorage.notification.icon, expectedIcon); + assert.equal(platformPayloadStorage.android.notification.icon, expectedIcon); }); it("should not set 'icon' if value is empty", () => { @@ -407,7 +528,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.icon = expectedIcon; - assert(!platformPayloadStorage.notification.icon); + assert(!platformPayloadStorage.android); }); it("should set 'tag'", () => { @@ -416,7 +537,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.tag = expectedTag; - assert.equal(platformPayloadStorage.notification.tag, expectedTag); + assert.equal(platformPayloadStorage.android.notification.tag, expectedTag); }); it("should not set 'tag' if value is empty", () => { @@ -425,7 +546,7 @@ describe('#notifications helper', () => { let builder = new FCMNotificationPayload(platformPayloadStorage); builder.tag = expectedTag; - assert(!platformPayloadStorage.notification.tag); + assert(!platformPayloadStorage.android); }); it('should return null when no data provided', () => { @@ -441,8 +562,8 @@ describe('#notifications helper', () => { let expectedNotification = { title: expectedTitle, body: expectedBody, - sound: expectedSound, }; + let expectedAndroidNotification = { sound: expectedSound }; let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); builder.sound = expectedSound; @@ -453,6 +574,8 @@ describe('#notifications helper', () => { assert(!payload.notification); assert(payload.data); assert.deepEqual(payload.data.notification, expectedNotification); + assert(payload.android); + assert.deepEqual(payload.android.notification, expectedAndroidNotification); }); it('should return valid payload object', () => { @@ -463,7 +586,9 @@ describe('#notifications helper', () => { notification: { title: expectedTitle, body: expectedBody, - sound: expectedSound, + }, + android: { + notification: { sound: expectedSound }, }, }; @@ -472,5 +597,158 @@ describe('#notifications helper', () => { assert.deepEqual(builder.toObject(), expectedPayload); }); + + it("should migrate legacy Android fields from 'notification'", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + const expectedSound = PubNub.generateUUID(); + const expectedIcon = PubNub.generateUUID(); + const expectedTag = PubNub.generateUUID(); + const expectedPayload = { + notification: { + title: expectedTitle, + body: expectedBody, + }, + android: { + notification: { + sound: expectedSound, + icon: expectedIcon, + tag: expectedTag, + }, + }, + }; + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + platformPayloadStorage.notification.sound = expectedSound; + platformPayloadStorage.notification.icon = expectedIcon; + platformPayloadStorage.notification.tag = expectedTag; + + assert.deepEqual(builder.toObject(), expectedPayload); + }); + + it("should preserve FCM platform blocks outside 'data'", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + const expectedSound = PubNub.generateUUID(); + const expectedPayload = { + notification: { + title: expectedTitle, + body: expectedBody, + }, + android: { + priority: 'high', + notification: { sound: expectedSound }, + }, + apns: { + headers: { 'apns-priority': '10' }, + }, + webpush: { + headers: { Urgency: 'high' }, + }, + fcm_options: { + analytics_label: 'campaign-a', + }, + }; + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + builder.sound = expectedSound; + platformPayloadStorage.android.priority = 'high'; + platformPayloadStorage.apns = { headers: { 'apns-priority': '10' } }; + platformPayloadStorage.webpush = { headers: { Urgency: 'high' } }; + platformPayloadStorage.fcm_options = { analytics_label: 'campaign-a' }; + + assert.deepEqual(builder.toObject(), expectedPayload); + }); + + it("should not include 'sound', 'icon', or 'tag' in top-level 'notification' output", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + const expectedSound = PubNub.generateUUID(); + const expectedIcon = PubNub.generateUUID(); + const expectedTag = PubNub.generateUUID(); + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + builder.sound = expectedSound; + builder.icon = expectedIcon; + builder.tag = expectedTag; + const payload = builder.toObject(); + + assert(payload); + assert(payload.notification); + assert.equal(Object.keys(payload.notification).length, 2); + assert.equal(payload.notification.title, expectedTitle); + assert.equal(payload.notification.body, expectedBody); + assert(!('sound' in payload.notification)); + assert(!('icon' in payload.notification)); + assert(!('tag' in payload.notification)); + assert(payload.android); + assert(payload.android.notification); + assert.equal(payload.android.notification.sound, expectedSound); + assert.equal(payload.android.notification.icon, expectedIcon); + assert.equal(payload.android.notification.tag, expectedTag); + }); + + it("should merge custom payload keys into 'data'", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + const customValue = PubNub.generateUUID(); + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + (platformPayloadStorage as Record).customKey = customValue; + const payload = builder.toObject(); + + assert(payload); + assert(payload.notification); + assert.equal(payload.notification.title, expectedTitle); + assert(payload.data); + assert.equal((payload.data as Record).customKey, customValue); + assert(!('customKey' in payload)); + }); + + it("should merge legacy 'notification' fields with existing 'android.notification' fields", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + const expectedIcon = PubNub.generateUUID(); + const expectedSound = PubNub.generateUUID(); + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + builder.icon = expectedIcon; + platformPayloadStorage.notification.sound = expectedSound; + const payload = builder.toObject(); + + assert(payload); + assert(payload.android); + assert(payload.android.notification); + assert.equal(payload.android.notification.icon, expectedIcon); + assert.equal(payload.android.notification.sound, expectedSound); + assert(!('sound' in payload.notification!)); + }); + + it("should return android payload when silent with no notification content", () => { + const expectedSound = PubNub.generateUUID(); + + let builder = new FCMNotificationPayload(platformPayloadStorage); + builder.sound = expectedSound; + builder.silent = true; + const payload = builder.toObject(); + + assert(payload); + assert(!payload.notification); + assert(!payload.data); + assert(payload.android); + assert.deepEqual(payload.android.notification, { sound: expectedSound }); + }); + + it("should expose 'notification' and 'data' getters referencing payload", () => { + const expectedTitle = PubNub.generateUUID(); + const expectedBody = PubNub.generateUUID(); + + let builder = new FCMNotificationPayload(platformPayloadStorage, expectedTitle, expectedBody); + + assert.strictEqual(builder.notification, platformPayloadStorage.notification); + assert.strictEqual(builder.data, platformPayloadStorage.data); + assert.equal(builder.notification!.title, expectedTitle); + assert.equal(builder.notification!.body, expectedBody); + }); }); }); From c31a02f0e40ac3cfeb994bc87423dea5dd1389d1 Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Wed, 4 Mar 2026 18:30:49 +0530 Subject: [PATCH 2/2] fix import and ts error for push payload --- src/core/components/push_payload.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/core/components/push_payload.ts b/src/core/components/push_payload.ts index 27e70ca2e..7d45bb268 100644 --- a/src/core/components/push_payload.ts +++ b/src/core/components/push_payload.ts @@ -1,3 +1,5 @@ +import type { Payload } from '../types/api'; + // -------------------------------------------------------- // ------------------------ Types ------------------------- // -------------------------------------------------------- @@ -187,7 +189,7 @@ type FCMPayload = { * * Silent notification configuration. */ - data?: { notification?: FCMPayload['notification'] } & Record; + data?: { notification?: FCMPayload['notification'] } & Record; /** * Android-specific options for notification delivery. @@ -206,22 +208,22 @@ type FCMPayload = { tag?: string; }; - } & Record; + } & Record; /** * APNS-specific options for notification delivery. */ - apns?: Record; + apns?: Record; /** * Web Push-specific options for notification delivery. */ - webpush?: Record; + webpush?: Record; /** * FCM service level options. */ - fcm_options?: Record; + fcm_options?: Record; }; // endregion // endregion @@ -850,7 +852,7 @@ export class FCMNotificationPayload extends BaseNotificationPayload { let android = this.payload.android ? { ...this.payload.android } : undefined; const payload: FCMPayload = {}; const { apns, webpush, fcm_options } = this.payload; - const additionalData = { ...this.payload } as Record; + const additionalData = { ...this.payload } as Record; android = this.moveLegacyAndroidNotificationFields(notification, android);