From cc2d9883c1705b4727edc410bba93792522cc2d1 Mon Sep 17 00:00:00 2001 From: Jano Detzel Date: Thu, 3 Apr 2025 15:26:58 +0200 Subject: [PATCH] feat(fcm): Support `apns.live_activity_token` field in FCM `ApnsConfig` * feat(fcm): Support `live_activity_token` field on `ApnsConfig` * added validation * update documentation --- etc/firebase-admin.messaging.api.md | 1 + src/messaging/messaging-api.ts | 4 + src/messaging/messaging-internal.ts | 17 +++ test/unit/messaging/messaging.spec.ts | 165 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index f0adaa0f35..04a1db9981 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -57,6 +57,7 @@ export interface AndroidNotification { // @public export interface ApnsConfig { + live_activity_token?: string; fcmOptions?: ApnsFcmOptions; headers?: { [key: string]: string; diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index 69c16382d5..2cb21caad2 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -241,6 +241,10 @@ export interface WebpushNotification { * Apple documentation} for various headers and payload fields supported by APNs. */ export interface ApnsConfig { + /** + * APN `live_activity_push_to_start_token` or `live_activity_push_token` to start or update live activities. + */ + live_activity_token?: string; /** * A collection of APNs headers. Header values must be strings. */ diff --git a/src/messaging/messaging-internal.ts b/src/messaging/messaging-internal.ts index 725769bb32..4dd11c3545 100644 --- a/src/messaging/messaging-internal.ts +++ b/src/messaging/messaging-internal.ts @@ -123,11 +123,28 @@ function validateApnsConfig(config: ApnsConfig | undefined): void { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_PAYLOAD, 'apns must be a non-null object'); } + validateApnsLiveActivityToken(config.live_activity_token); validateStringMap(config.headers, 'apns.headers'); validateApnsPayload(config.payload); validateApnsFcmOptions(config.fcmOptions); } +function validateApnsLiveActivityToken(liveActivityToken: ApnsConfig['live_activity_token']): void { + if (typeof liveActivityToken === 'undefined') { + return; + } else if (!validator.isString(liveActivityToken)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_PAYLOAD, + 'apns.live_activity_token must be a string value', + ); + } else if (!validator.isNonEmptyString(liveActivityToken)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_PAYLOAD, + 'apns.live_activity_token must be a non-empty string', + ); + } +} + /** * Checks if the given ApnsFcmOptions object is valid. * diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index 79942f73d2..a16369261a 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -1725,6 +1725,21 @@ describe('Messaging', () => { }); }); + const invalidApnsLiveActivityTokens: any[] = [null, NaN, 0, 1, true, false] + invalidApnsLiveActivityTokens.forEach((arg) => { + it(`should throw given invalid apns live activity token: ${JSON.stringify(arg)}`, () => { + expect(() => { + messaging.send({ apns: { live_activity_token: arg }, topic: 'test' }); + }).to.throw('apns.live_activity_token must be a string value'); + }); + }) + + it('should throw given empty apns live activity token', () => { + expect(() => { + messaging.send({ apns: { live_activity_token: '' }, topic: 'test' }); + }).to.throw('apns.live_activity_token must be a non-empty string'); + }); + const invalidApnsPayloads: any[] = [null, '', 'payload', true, 1.23]; invalidApnsPayloads.forEach((payload) => { it(`should throw given APNS payload with invalid object: ${JSON.stringify(payload)}`, () => { @@ -2388,6 +2403,155 @@ describe('Messaging', () => { }, }, }, + { + label: 'APNS Start LiveActivity', + req: { + apns: { + live_activity_token: 'live-activity-token', + headers:{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 + }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers:{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 + }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + }, + { + label: 'APNS Update LiveActivity', + req: { + apns: { + live_activity_token: 'live-activity-token', + headers:{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers:{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + }, + { + label: 'APNS End LiveActivity', + req: { + apns: { + live_activity_token: 'live-activity-token', + 'headers':{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + 'headers':{ + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, + }, + }, + }, ]; validMessages.forEach((config) => { @@ -2404,6 +2568,7 @@ describe('Messaging', () => { .then(() => { const expectedReq = config.expectedReq || config.req; expectedReq.token = 'mock-token'; + expect(httpsRequestStub).to.have.been.calledOnce.and.calledWith({ method: 'POST', data: { message: expectedReq },