From aa2f500d3260b55c8c0b281198fa954ec8b0d421 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Mon, 6 Jul 2026 13:37:04 -0400 Subject: [PATCH 1/3] feat(messaging): add support for Android Config V2 --- etc/firebase-admin.messaging.api.md | 71 ++++++ src/messaging/index.ts | 9 +- src/messaging/messaging-api.ts | 331 +++++++++++++++++++++++++- src/messaging/messaging-internal.ts | 208 +++++++++++++++- test/unit/messaging/messaging.spec.ts | 298 +++++++++++++++++++++++ 5 files changed, 905 insertions(+), 12 deletions(-) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index 9de4e09d13..b618e957fb 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -6,6 +6,15 @@ import { Agent } from 'http'; +// @public +export interface AndroidBackgroundSyncConfig extends AndroidConfigV2Base { + backgroundSync: AndroidBackgroundSyncMessage; + remoteNotification?: never; +} + +// @public +export type AndroidBackgroundSyncMessage = Record; + // @public export interface AndroidConfig { bandwidthConstrainedOk?: boolean; @@ -22,6 +31,23 @@ export interface AndroidConfig { ttl?: number; } +// @public +export type AndroidConfigV2 = AndroidRemoteNotificationConfig | AndroidBackgroundSyncConfig; + +// @public +export interface AndroidConfigV2Base { + bandwidthConstrainedOk?: boolean; + collapseKey?: string; + data?: { + [key: string]: string; + }; + directBootOk?: boolean; + fcmOptions?: AndroidFcmOptions; + restrictedPackageName?: string; + restrictedSatelliteOk?: boolean; + ttl?: number; +} + // @public export interface AndroidFcmOptions { analyticsLabel?: string; @@ -57,6 +83,49 @@ export interface AndroidNotification { visibility?: ('private' | 'public' | 'secret'); } +// @public +export interface AndroidNotificationV2 { + body?: string; + bodyLocArgs?: string[]; + bodyLocKey?: string; + channelId?: string; + clickAction?: string; + color?: string; + defaultLightSettings?: boolean; + defaultSound?: boolean; + defaultVibrateTimings?: boolean; + eventTime?: Date; + icon?: string; + id?: string; + imageUrl?: string; + lightSettings?: LightSettings; + localOnly?: boolean; + notificationCount?: number; + priority?: ('min' | 'low' | 'default' | 'high' | 'max'); + sound?: string; + sticky?: boolean; + tag?: string; + ticker?: string; + title?: string; + titleLocArgs?: string[]; + titleLocKey?: string; + vibrateTimingsMillis?: number[]; + visibility?: ('private' | 'public' | 'secret'); +} + +// @public +export interface AndroidRemoteNotification { + mutableContent?: boolean; + notification: AndroidNotificationV2; + useAsV1DataMessage?: boolean; +} + +// @public +export interface AndroidRemoteNotificationConfig extends AndroidConfigV2Base { + backgroundSync?: never; + remoteNotification: AndroidRemoteNotification; +} + // @public export interface ApnsConfig { fcmOptions?: ApnsFcmOptions; @@ -124,6 +193,8 @@ export interface BaseMessage { // (undocumented) android?: AndroidConfig; // (undocumented) + androidV2?: AndroidConfigV2; + // (undocumented) apns?: ApnsConfig; // (undocumented) data?: { diff --git a/src/messaging/index.ts b/src/messaging/index.ts index 5f45d36dac..c7c39d3826 100644 --- a/src/messaging/index.ts +++ b/src/messaging/index.ts @@ -30,8 +30,15 @@ export { export { AndroidConfig, - AndroidFcmOptions, + AndroidConfigV2, + AndroidConfigV2Base, + AndroidRemoteNotificationConfig, + AndroidBackgroundSyncConfig, + AndroidRemoteNotification, + AndroidBackgroundSyncMessage, AndroidNotification, + AndroidNotificationV2, + AndroidFcmOptions, ApnsConfig, ApnsFcmOptions, ApnsPayload, diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index a0572dc903..a632e00983 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -18,9 +18,10 @@ import { FirebaseArrayIndexError, FirebaseError } from '../app/index'; export interface BaseMessage { - data?: { [key: string]: string }; + data?: { [key: string]: string; }; notification?: Notification; android?: AndroidConfig; + androidV2?: AndroidConfigV2; webpush?: WebpushConfig; apns?: ApnsConfig; fcmOptions?: FcmOptions; @@ -131,12 +132,12 @@ export interface WebpushConfig { * See {@link https://tools.ietf.org/html/rfc8030#section-5 | WebPush specification} * for supported headers. */ - headers?: { [key: string]: string }; + headers?: { [key: string]: string; }; /** * A collection of data fields. */ - data?: { [key: string]: string }; + data?: { [key: string]: string; }; /** * A WebPush notification payload to be included in the message. @@ -287,7 +288,7 @@ export interface ApnsConfig { /** * A collection of APNs headers. Header values must be strings. */ - headers?: { [key: string]: string }; + headers?: { [key: string]: string; }; /** * An APNs payload to be included in the message. @@ -450,7 +451,7 @@ export interface AndroidConfig { * be strings. When provided, overrides any data fields set on the top-level * {@link Message}. */ - data?: { [key: string]: string }; + data?: { [key: string]: string; }; /** * Android notification to be included in the message. @@ -469,13 +470,13 @@ export interface AndroidConfig { directBootOk?: boolean; /** - * A boolean indicating whether messages will be allowed to be delivered to + * A boolean indicating whether messages will be allowed to be delivered to * the app while the device is on a bandwidth constrained network. */ bandwidthConstrainedOk?: boolean; /** - * A boolean indicating whether messages will be allowed to be delivered to + * A boolean indicating whether messages will be allowed to be delivered to * the app while the device is on a restricted satellite network. */ restrictedSatelliteOk?: boolean; @@ -536,7 +537,6 @@ export interface AndroidNotification { /** * Key of the body string in the app's string resource to use to localize the * body text. - * */ bodyLocKey?: string; @@ -781,3 +781,318 @@ export interface SendResponse { */ error?: FirebaseError; } + +/** + * Represents the Android-specific base configuration options (V2) that can be + * included in an {@link Message}. + */ +export interface AndroidConfigV2Base { + /** + * Collapse key for the message. Collapse key serves as an identifier for a + * group of messages that can be collapsed, so that only the last message gets + * sent when delivery can be resumed. A maximum of four different collapse keys + * may be active at any given time. + */ + collapseKey?: string; + + /** + * Time-to-live duration of the message in milliseconds. + */ + ttl?: number; + + /** + * Package name of the application where the registration tokens must match + * in order to receive the message. + */ + restrictedPackageName?: string; + + /** + * A collection of data fields to be included in the message. All values must + * be strings. When provided, overrides any data fields set on the top-level + * {@link Message}. + */ + data?: { + [key: string]: string; + }; + + /** + * Options for features provided by the FCM SDK for Android. + */ + fcmOptions?: AndroidFcmOptions; + + /** + * A boolean indicating whether messages will be allowed to be delivered to + * the app while the device is in direct boot mode. + */ + directBootOk?: boolean; + + /** + * A boolean indicating whether messages will be allowed to be delivered to + * the app while the device is on a bandwidth constrained network. + */ + bandwidthConstrainedOk?: boolean; + + /** + * A boolean indicating whether messages will be allowed to be delivered to + * the app while the device is on a restricted satellite network. + */ + restrictedSatelliteOk?: boolean; +} + +/** + * Represents the Android-specific remote notification configuration (V2) that can be + * included in an {@link Message}. + */ +export interface AndroidRemoteNotificationConfig extends AndroidConfigV2Base { + /** + * Options for a remote notification message. + */ + remoteNotification: AndroidRemoteNotification; + + /** + * Background sync options are forbidden when remoteNotification is present. + */ + backgroundSync?: never; +} + +/** + * Represents the Android-specific background sync configuration (V2) that can be + * included in an {@link Message}. + */ +export interface AndroidBackgroundSyncConfig extends AndroidConfigV2Base { + /** + * Options for a background sync message. + */ + backgroundSync: AndroidBackgroundSyncMessage; + + /** + * Remote notification options are forbidden when backgroundSync is present. + */ + remoteNotification?: never; +} + +/** + * Represents the Android-specific options (V2) that can be included in an + * {@link Message}. + */ +export type AndroidConfigV2 = AndroidRemoteNotificationConfig | AndroidBackgroundSyncConfig; + +/** + * Represents the Android-specific remote notification options (V2) that can be + * included in {@link AndroidRemoteNotificationConfig}. + */ +export interface AndroidRemoteNotification { + /** + * If set to true, the client can modify the notification payload. + */ + mutableContent?: boolean; + + /** + * Android notification to be included in the message. + */ + notification: AndroidNotificationV2; + + /** + * Controls how legacy V1 clients interpret a V2 remote notification. + * + * If set to `true`, tells legacy clients to omit the notification payload, and deliver + * the message as a data message. If `false` (default), the message is auto-translated + * into a legacy V1 notification message. + */ + useAsV1DataMessage?: boolean; +} + +/** + * Represents the Android-specific background sync options (V2) that can be + * included in {@link AndroidBackgroundSyncConfig}. + */ +export type AndroidBackgroundSyncMessage = Record; + +/** + * Represents the Android-specific notification options (V2) that can be + * included in {@link AndroidRemoteNotification}. + */ +export interface AndroidNotificationV2 { + /** + * Title of the Android notification. When provided, overrides the title set via + * `admin.messaging.Notification`. + */ + title?: string; + + /** + * Body of the Android notification. When provided, overrides the body set via + * `admin.messaging.Notification`. + */ + body?: string; + + /** + * Icon resource for the Android notification. + */ + icon?: string; + + /** + * Notification icon color in `#rrggbb` format. + */ + color?: string; + + /** + * File name of the sound to be played when the device receives the + * notification. + */ + sound?: string; + + /** + * Notification tag. This is an identifier used to replace existing + * notifications in the notification drawer. If not specified, each request + * creates a new notification. + */ + tag?: string; + + /** + * Identifier of the notification. + */ + id?: string; + + /** + * Action associated with a user click on the notification. If specified, an + * activity with a matching Intent Filter is launched when a user clicks on the + * notification. + */ + clickAction?: string; + + /** + * Key of the body string in the app's string resource to use to localize the + * body text. + */ + bodyLocKey?: string; + + /** + * An array of resource keys that will be used in place of the format + * specifiers in `bodyLocKey`. + */ + bodyLocArgs?: string[]; + + /** + * Key of the title string in the app's string resource to use to localize the + * title text. + */ + titleLocKey?: string; + + /** + * An array of resource keys that will be used in place of the format + * specifiers in `titleLocKey`. + */ + titleLocArgs?: string[]; + + /** + * The Android notification channel ID (new in Android O). The app must create + * a channel with this channel ID before any notification with this channel ID + * can be received. If you don't send this channel ID in the request, or if the + * channel ID provided has not yet been created by the app, FCM uses the channel + * ID specified in the app manifest. + */ + channelId?: string; + + /** + * Sets the "ticker" text, which is sent to accessibility services. Prior to + * API level 21 (Lollipop), sets the text that is displayed in the status bar + * when the notification first arrives. + */ + ticker?: string; + + /** + * When set to `false` or unset, the notification is automatically dismissed when + * the user clicks it in the panel. When set to `true`, the notification persists + * even when the user clicks it. + */ + sticky?: boolean; + + /** + * For notifications that inform users about events with an absolute time reference, sets + * the time that the event in the notification occurred. Notifications + * in the panel are sorted by this time. + */ + eventTime?: Date; + + /** + * Sets whether or not this notification is relevant only to the current device. + * Some notifications can be bridged to other devices for remote display, such as + * a Wear OS watch. This hint can be set to recommend this notification not be bridged. + * See {@link https://developer.android.com/training/wearables/notifications/bridger#existing-method-of-preventing-bridging | + * Wear OS guides}. + */ + localOnly?: boolean; + + /** + * Sets the relative priority for this notification. Low-priority notifications + * may be hidden from the user in certain situations. Note this priority differs + * from `AndroidMessagePriority`. This priority is processed by the client after + * the message has been delivered. Whereas `AndroidMessagePriority` is an FCM concept + * that controls when the message is delivered. + */ + priority?: ('min' | 'low' | 'default' | 'high' | 'max'); + + /** + * If set to `true`, use the Android framework's default sound for the notification. + * Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | + * config.xml}. + */ + defaultSound?: boolean; + + /** + * If set to `true`, use the Android framework's default vibrate pattern for the + * notification. Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | + * config.xml}. If `defaultVibrateTimings` is set to `true` and `vibrateTimingsMillis` is also set, + * the default value is used instead of the user-specified `vibrateTimingsMillis`. + */ + defaultVibrateTimings?: boolean; + + /** + * If set to `true`, use the Android framework's default LED light settings + * for the notification. Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | + * config.xml}. + * If `default_light_settings` is set to `true` and `light_settings` is also set, + * the user-specified `light_settings` is used instead of the default value. + */ + defaultLightSettings?: boolean; + + /** + * Sets the vibration pattern to use. Pass in an array of milliseconds to + * turn the vibrator on or off. The first value indicates the duration to wait before + * turning the vibrator on. The next value indicates the duration to keep the + * vibrator on. Subsequent values alternate between duration to turn the vibrator + * off and to turn the vibrator on. If `vibrateTimingsMillis` is set and `defaultVibrateTimings` + * is set to `true`, the default value is used instead of the user-specified `vibrateTimingsMillis`. + */ + vibrateTimingsMillis?: number[]; + + /** + * Sets the visibility of the notification. Must be either `private`, `public`, + * or `secret`. If unspecified, it remains undefined in the Admin SDK, and + * defers to the FCM backend's default mapping. + */ + visibility?: ('private' | 'public' | 'secret'); + + /** + * Sets the number of items this notification represents. May be displayed as a + * badge count for Launchers that support badging. See {@link https://developer.android.com/training/notify-user/badges | + * NotificationBadge}. + * For example, this might be useful if you're using just one notification to + * represent multiple new messages but you want the count here to represent + * the number of total new messages. If zero or unspecified, systems + * that support badging use the default, which is to increment a number + * displayed on the long-press menu each time a new notification arrives. + */ + notificationCount?: number; + + /** + * Settings to control the notification's LED blinking rate and color if LED is + * available on the device. The total blinking time is controlled by the OS. + */ + lightSettings?: LightSettings; + + /** + * URL of an image to be displayed in the notification. + */ + imageUrl?: string; +} diff --git a/src/messaging/messaging-internal.ts b/src/messaging/messaging-internal.ts index 4d980730d1..dd593398ff 100644 --- a/src/messaging/messaging-internal.ts +++ b/src/messaging/messaging-internal.ts @@ -19,9 +19,9 @@ import { messagingClientErrorCode, FirebaseMessagingError } from './error'; import * as validator from '../utils/validator'; import { - AndroidConfig, AndroidFcmOptions, AndroidNotification, ApsAlert, ApnsConfig, - ApnsFcmOptions, ApnsPayload, Aps, CriticalSound, FcmOptions, LightSettings, Message, - Notification, WebpushConfig, + AndroidConfig, AndroidConfigV2, AndroidFcmOptions, AndroidNotification, + AndroidNotificationV2, ApsAlert, ApnsConfig, ApnsFcmOptions, ApnsPayload, Aps, + CriticalSound, FcmOptions, LightSettings, Message, Notification, WebpushConfig, } from './messaging-api'; // Keys which are not allowed in the messaging data payload object. @@ -65,8 +65,16 @@ export function validateMessage(message: Message): void { 'Exactly one of fid, topic, token or condition is required'); } + if (message.android && message.androidV2) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'Exactly one of android and androidV2 can be set. Please use ' + + 'androidV2 instead of the legacy android field.'); + } + validateStringMap(message.data, 'data'); validateAndroidConfig(message.android); + validateAndroidConfigV2(message.androidV2); validateWebpushConfig(message.webpush); validateApnsConfig(message.apns); validateFcmOptions(message.fcmOptions); @@ -428,6 +436,200 @@ function validateAndroidConfig(config: AndroidConfig | undefined): void { renameProperties(config, propertyMappings); } +/** + * Checks if the given AndroidConfigV2 object is valid. The object must have valid ttl, data, + * and messageType fields. If successful, transforms the input object by renaming keys to valid + * Android keys. Also transforms the ttl value to the format expected by FCM service. + * + * @param config - An object to be validated. + */ +function validateAndroidConfigV2(config: AndroidConfigV2 | undefined): void { + if (typeof config === 'undefined') { + return; + } else if (!validator.isNonNullObject(config)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, 'androidV2 must be a non-null object'); + } + + if (typeof config.ttl !== 'undefined') { + if (!validator.isNumber(config.ttl) || config.ttl < 0) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'TTL must be a non-negative duration in milliseconds'); + } + const duration: string = transformMillisecondsToSecondsString(config.ttl); + (config as any).ttl = duration; + } + validateStringMap(config.data, 'androidV2.data'); + validateAndroidFcmOptions(config.fcmOptions); + + const hasRemoteNotification = typeof config.remoteNotification !== 'undefined'; + const hasBackgroundSync = typeof config.backgroundSync !== 'undefined'; + + if (hasRemoteNotification === hasBackgroundSync) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'Exactly one of remoteNotification or backgroundSync is required'); + } + + if (hasRemoteNotification) { + const remoteNotification = config.remoteNotification!; + if (!validator.isNonNullObject(remoteNotification)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification must be a non-null object'); + } + + if (typeof remoteNotification.notification === 'undefined') { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification is required'); + } + validateAndroidNotificationV2(remoteNotification.notification); + + if (typeof remoteNotification.mutableContent !== 'undefined' + && !validator.isBoolean(remoteNotification.mutableContent)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.mutableContent must be a boolean'); + } + if (typeof remoteNotification.useAsV1DataMessage !== 'undefined' + && !validator.isBoolean(remoteNotification.useAsV1DataMessage)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.useAsV1DataMessage must be a boolean'); + } + + (config as any).remote_notification = { + mutable_content: remoteNotification.mutableContent, + notification: remoteNotification.notification, + use_as_v1_data_message: remoteNotification.useAsV1DataMessage, + }; + } else if (hasBackgroundSync) { + const backgroundSync = config.backgroundSync!; + if (!validator.isNonNullObject(backgroundSync)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.backgroundSync must be a non-null object'); + } + (config as any).background_sync = {}; + } + + delete (config as any).remoteNotification; + delete (config as any).backgroundSync; + + const propertyMappings = { + collapseKey: 'collapse_key', + restrictedPackageName: 'restricted_package_name', + directBootOk: 'direct_boot_ok', + bandwidthConstrainedOk: 'bandwidth_constrained_ok', + restrictedSatelliteOk: 'restricted_satellite_ok', + }; + renameProperties(config, propertyMappings); +} + +/** + * Checks if the given AndroidNotificationV2 object is valid. The object must have valid color and + * localization parameters. If successful, transforms the input object by renaming keys to valid + * Android keys. + * + * @param {AndroidNotificationV2} notification An object to be validated. + */ +function validateAndroidNotificationV2(notification: AndroidNotificationV2 | undefined): void { + if (typeof notification === 'undefined') { + return; + } else if (!validator.isNonNullObject(notification)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, 'androidV2.remoteNotification.notification must be a non-null object'); + } + + if (typeof notification.color !== 'undefined' && !/^#[0-9a-fA-F]{6}$/.test(notification.color)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.color must be in the form #RRGGBB'); + } + if (validator.isNonEmptyArray(notification.bodyLocArgs) && + !validator.isNonEmptyString(notification.bodyLocKey)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.bodyLocKey is required when specifying bodyLocArgs'); + } + if (validator.isNonEmptyArray(notification.titleLocArgs) && + !validator.isNonEmptyString(notification.titleLocKey)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.titleLocKey is required when specifying titleLocArgs'); + } + if (typeof notification.imageUrl !== 'undefined' && + !validator.isURL(notification.imageUrl)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.imageUrl must be a valid URL string'); + } + if (typeof notification.eventTime !== 'undefined') { + if (!(notification.eventTime instanceof Date)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.eventTime must be a valid `Date` object'); + } + // Convert timestamp to RFC3339 UTC "Zulu" format, example "2014-10-02T15:01:23.045123456Z" + const zuluTimestamp = notification.eventTime.toISOString(); + (notification as any).eventTime = zuluTimestamp; + } + + if (typeof notification.vibrateTimingsMillis !== 'undefined') { + if (!validator.isNonEmptyArray(notification.vibrateTimingsMillis)) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.vibrateTimingsMillis must be a non-empty array of numbers'); + } + const vibrateTimings: string[] = []; + notification.vibrateTimingsMillis.forEach((value) => { + if (!validator.isNumber(value) || value < 0) { + throw new FirebaseMessagingError( + messagingClientErrorCode.INVALID_PAYLOAD, + 'androidV2.remoteNotification.notification.vibrateTimingsMillis ' + + 'must be non-negative durations in milliseconds'); + } + const duration = transformMillisecondsToSecondsString(value); + vibrateTimings.push(duration); + }); + (notification as any).vibrateTimingsMillis = vibrateTimings; + } + + if (typeof notification.priority !== 'undefined') { + const priority = 'PRIORITY_' + notification.priority.toUpperCase(); + (notification as any).priority = priority; + } + + if (typeof notification.visibility !== 'undefined') { + const visibility = notification.visibility.toUpperCase(); + (notification as any).visibility = visibility; + } + + validateLightSettings(notification.lightSettings); + + const propertyMappings = { + clickAction: 'click_action', + bodyLocKey: 'body_loc_key', + bodyLocArgs: 'body_loc_args', + titleLocKey: 'title_loc_key', + titleLocArgs: 'title_loc_args', + channelId: 'channel_id', + imageUrl: 'image', + eventTime: 'event_time', + localOnly: 'local_only', + priority: 'notification_priority', + vibrateTimingsMillis: 'vibrate_timings', + defaultVibrateTimings: 'default_vibrate_timings', + defaultSound: 'default_sound', + lightSettings: 'light_settings', + defaultLightSettings: 'default_light_settings', + notificationCount: 'notification_count', + }; + renameProperties(notification, propertyMappings); +} + /** * Checks if the given AndroidNotification object is valid. The object must have valid color and * localization parameters. If successful, transforms the input object by renaming keys to valid diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index bf6e0808e2..81e9eb78e4 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -1929,6 +1929,173 @@ describe('Messaging', () => { messaging.send({ apns: { fcmOptions: arg }, topic: 'test' }); }).to.throw('fcmOptions must be a non-null object'); }); + + it(`should throw given invalid androidV2 config: ${JSON.stringify(arg)}`, () => { + expect(() => { + messaging.send({ androidV2: arg, topic: 'test' }); + }).to.throw('androidV2 must be a non-null object'); + }); + + it(`should throw given invalid androidV2 notification: ${JSON.stringify(arg)}`, () => { + expect(() => { + messaging.send({ + androidV2: { + remoteNotification: { + notification: arg, + }, + } as any, + topic: 'test', + }); + }).to.throw('androidV2.remoteNotification.notification must be a non-null object'); + }); + }); + + it('should throw given both android and androidV2 config', () => { + expect(() => { + messaging.send({ + android: {}, + androidV2: { + remoteNotification: { + notification: {}, + }, + }, + topic: 'test', + } as any); + }).to.throw( + 'Exactly one of android and androidV2 can be set. Please use ' + + 'androidV2 instead of the legacy android field.' + ); + }); + + it('should throw given androidV2 missing both wrappers', () => { + expect(() => { + messaging.send({ androidV2: {} as any, topic: 'test' }); + }).to.throw('Exactly one of remoteNotification or backgroundSync is required'); + }); + + it('should throw given androidV2 containing both wrappers', () => { + expect(() => { + messaging.send({ + androidV2: { + remoteNotification: { + notification: {}, + }, + backgroundSync: {}, + } as any, + topic: 'test', + }); + }).to.throw('Exactly one of remoteNotification or backgroundSync is required'); + }); + + it('should throw given androidV2 remoteNotification without notification', () => { + expect(() => { + messaging.send({ + androidV2: { + remoteNotification: { + mutableContent: true, + }, + } as any, + topic: 'test', + }); + }).to.throw('androidV2.remoteNotification.notification is required'); + }); + + it('should throw given androidV2 remoteNotification with invalid useAsV1DataMessage', () => { + expect(() => { + messaging.send({ + androidV2: { + remoteNotification: { + notification: {}, + useAsV1DataMessage: 'invalid', + }, + } as any, + topic: 'test', + }); + }).to.throw('androidV2.remoteNotification.useAsV1DataMessage must be a boolean'); + }); + + it('should throw given androidV2 titleLocArgs without titleLocKey', () => { + const message: Message = { + condition: 'topic-name', + androidV2: { + remoteNotification: { + notification: { + titleLocArgs: ['foo'], + }, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('androidV2.remoteNotification.notification.titleLocKey is required when specifying titleLocArgs'); + }); + + it('should throw given androidV2 bodyLocArgs without bodyLocKey', () => { + const message: Message = { + condition: 'topic-name', + androidV2: { + remoteNotification: { + notification: { + bodyLocArgs: ['foo'], + }, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('androidV2.remoteNotification.notification.bodyLocKey is required when specifying bodyLocArgs'); + }); + + it('should throw given androidV2 notification with invalid vibrateTimingsMillis', () => { + const message: Message = { + condition: 'topic-name', + androidV2: { + remoteNotification: { + notification: { + vibrateTimingsMillis: [], + }, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw( + 'androidV2.remoteNotification.notification.vibrateTimingsMillis must be a non-empty array of numbers' + ); + }); + + it('should throw given androidV2 notification with negative vibrateTimingsMillis', () => { + const message: Message = { + condition: 'topic-name', + androidV2: { + remoteNotification: { + notification: { + vibrateTimingsMillis: [-100], + }, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw( + 'androidV2.remoteNotification.notification.vibrateTimingsMillis must be non-negative durations in milliseconds' + ); + }); + + it('should throw given androidV2 notification with invalid eventTime', () => { + const message: Message = { + condition: 'topic-name', + androidV2: { + remoteNotification: { + notification: { + eventTime: 123456 as any, + }, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('androidV2.remoteNotification.notification.eventTime must be a valid `Date` object'); }); invalidImages.forEach((imageUrl) => { @@ -2791,6 +2958,137 @@ describe('Messaging', () => { }, }, }, + { + label: 'Android Config V2 with Remote Notification', + req: { + androidV2: { + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + directBootOk: true, + bandwidthConstrainedOk: true, + restrictedSatelliteOk: true, + ttl: 5000, + data: { + k1: 'v1', + k2: 'v2', + }, + remoteNotification: { + mutableContent: true, + useAsV1DataMessage: true, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + id: 'test.id', + imageUrl: 'https://example.com/image.png', + clickAction: 'test.click.action', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + bodyLocKey: 'body.loc.key', + bodyLocArgs: ['arg1', 'arg2'], + channelId: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'private', + eventTime: new Date('2019-10-20T12:00:00-06:30'), + localOnly: true, + priority: 'high', + vibrateTimingsMillis: [100, 50, 250], + defaultVibrateTimings: false, + defaultSound: true, + lightSettings: { + color: '#AABBCC', + lightOnDurationMillis: 200, + lightOffDurationMillis: 300, + }, + defaultLightSettings: false, + notificationCount: 1, + }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + }, + }, + }, + expectedReq: { + androidV2: { + collapse_key: 'test.key', + restricted_package_name: 'test.package', + direct_boot_ok: true, + bandwidth_constrained_ok: true, + restricted_satellite_ok: true, + ttl: '5s', + data: { + k1: 'v1', + k2: 'v2', + }, + remote_notification: { + mutable_content: true, + use_as_v1_data_message: true, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + id: 'test.id', + image: 'https://example.com/image.png', + click_action: 'test.click.action', + title_loc_key: 'title.loc.key', + title_loc_args: ['arg1', 'arg2'], + body_loc_key: 'body.loc.key', + body_loc_args: ['arg1', 'arg2'], + channel_id: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'PRIVATE', + event_time: '2019-10-20T18:30:00.000Z', + local_only: true, + notification_priority: 'PRIORITY_HIGH', + vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], + default_vibrate_timings: false, + default_sound: true, + light_settings: { + color: { + red: 0.6666666666666666, + green: 0.7333333333333333, + blue: 0.8, + alpha: 1, + }, + light_on_duration: '0.200000000s', + light_off_duration: '0.300000000s', + }, + default_light_settings: false, + notification_count: 1, + }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + }, + }, + }, + }, + { + label: 'Android Config V2 with Background Sync', + req: { + androidV2: { + collapseKey: 'test.key', + ttl: 5000, + backgroundSync: {}, + }, + }, + expectedReq: { + androidV2: { + collapse_key: 'test.key', + ttl: '5s', + background_sync: {}, + }, + }, + }, ]; validMessages.forEach((config) => { From fc01c79173066a6e3faac2d084d8cba37bba5327 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Mon, 6 Jul 2026 14:44:16 -0400 Subject: [PATCH 2/3] fix(messaging): refine androidV2 validation and property renaming --- src/messaging/messaging-internal.ts | 20 +++++++++----------- test/unit/messaging/messaging.spec.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/messaging/messaging-internal.ts b/src/messaging/messaging-internal.ts index dd593398ff..e82621c14d 100644 --- a/src/messaging/messaging-internal.ts +++ b/src/messaging/messaging-internal.ts @@ -500,30 +500,28 @@ function validateAndroidConfigV2(config: AndroidConfigV2 | undefined): void { 'androidV2.remoteNotification.useAsV1DataMessage must be a boolean'); } - (config as any).remote_notification = { - mutable_content: remoteNotification.mutableContent, - notification: remoteNotification.notification, - use_as_v1_data_message: remoteNotification.useAsV1DataMessage, - }; + renameProperties(remoteNotification, { + mutableContent: 'mutable_content', + useAsV1DataMessage: 'use_as_v1_data_message', + }); } else if (hasBackgroundSync) { const backgroundSync = config.backgroundSync!; - if (!validator.isNonNullObject(backgroundSync)) { + if (!validator.isNonNullObject(backgroundSync) || Object.keys(backgroundSync).length > 0) { throw new FirebaseMessagingError( messagingClientErrorCode.INVALID_PAYLOAD, - 'androidV2.backgroundSync must be a non-null object'); + 'androidV2.backgroundSync must be an empty object'); } - (config as any).background_sync = {}; + config.backgroundSync = {}; } - delete (config as any).remoteNotification; - delete (config as any).backgroundSync; - const propertyMappings = { collapseKey: 'collapse_key', restrictedPackageName: 'restricted_package_name', directBootOk: 'direct_boot_ok', bandwidthConstrainedOk: 'bandwidth_constrained_ok', restrictedSatelliteOk: 'restricted_satellite_ok', + remoteNotification: 'remote_notification', + backgroundSync: 'background_sync', }; renameProperties(config, propertyMappings); } diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index 81e9eb78e4..93b954af02 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -1987,6 +1987,16 @@ describe('Messaging', () => { }).to.throw('Exactly one of remoteNotification or backgroundSync is required'); }); + it('should throw given androidV2 backgroundSync with extraneous properties', () => { + expect(() => { + messaging.send({ + androidV2: { + backgroundSync: { foo: 'bar' }, + } as any, + topic: 'test', + }); + }).to.throw('androidV2.backgroundSync must be an empty object'); + }); it('should throw given androidV2 remoteNotification without notification', () => { expect(() => { messaging.send({ From 198ab5adc0d8a747097da22f260263229383c7f0 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Wed, 8 Jul 2026 16:30:04 -0400 Subject: [PATCH 3/3] chore(fcm): deprecate v1 `AndroidConfig` and `AndroidNotification` APIs --- etc/firebase-admin.messaging.api.md | 7 ++++--- src/messaging/messaging-api.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index b618e957fb..94aad932b7 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -15,7 +15,7 @@ export interface AndroidBackgroundSyncConfig extends AndroidConfigV2Base { // @public export type AndroidBackgroundSyncMessage = Record; -// @public +// @public @deprecated export interface AndroidConfig { bandwidthConstrainedOk?: boolean; collapseKey?: string; @@ -24,6 +24,7 @@ export interface AndroidConfig { }; directBootOk?: boolean; fcmOptions?: AndroidFcmOptions; + // @deprecated notification?: AndroidNotification; priority?: ('high' | 'normal'); restrictedPackageName?: string; @@ -53,7 +54,7 @@ export interface AndroidFcmOptions { analyticsLabel?: string; } -// @public +// @public @deprecated export interface AndroidNotification { body?: string; bodyLocArgs?: string[]; @@ -190,7 +191,7 @@ export interface ApsAlert { // @public (undocumented) export interface BaseMessage { - // (undocumented) + // @deprecated (undocumented) android?: AndroidConfig; // (undocumented) androidV2?: AndroidConfigV2; diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index a632e00983..683e92e58b 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -20,6 +20,9 @@ import { FirebaseArrayIndexError, FirebaseError } from '../app/index'; export interface BaseMessage { data?: { [key: string]: string; }; notification?: Notification; + /** + * @deprecated Use {@link BaseMessage.androidV2} instead. + */ android?: AndroidConfig; androidV2?: AndroidConfigV2; webpush?: WebpushConfig; @@ -419,6 +422,8 @@ export interface ApnsFcmOptions { /** * Represents the Android-specific options that can be included in an * {@link Message}. + * + * @deprecated Use {@link AndroidConfigV2} instead. */ export interface AndroidConfig { @@ -455,6 +460,8 @@ export interface AndroidConfig { /** * Android notification to be included in the message. + * + * @deprecated Use {@link AndroidNotificationV2} in {@link AndroidConfigV2} instead. */ notification?: AndroidNotification; @@ -485,6 +492,8 @@ export interface AndroidConfig { /** * Represents the Android-specific notification options that can be included in * {@link AndroidConfig}. + * + * @deprecated Use {@link AndroidNotificationV2} in {@link AndroidConfigV2} instead. */ export interface AndroidNotification { /**