Skip to content

Commit 363d0a2

Browse files
feat(fcm): Add 12 new Android Notification Parameters Support (#684)
Discussion - Introduce support for the new Android notification parameters in FCM API to AdminSDK. Testing - Added unit tests and integration tests to reflect this change. API Changes - In Messaging added new fields to AndroidNotification interface. - Introduced LightSettings interface RELEASE NOTE: Added a series of new parameters to the AndroidNotification class that allow further customization of notifications that target Android devices.
1 parent 6efc63d commit 363d0a2

File tree

4 files changed

+444
-12
lines changed

4 files changed

+444
-12
lines changed

src/index.d.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4000,6 +4000,120 @@ declare namespace admin.messaging {
40004000
* ID specified in the app manifest.
40014001
*/
40024002
channelId?: string;
4003+
4004+
/**
4005+
* Sets the "ticker" text, which is sent to accessibility services. Prior to
4006+
* API level 21 (Lollipop), sets the text that is displayed in the status bar
4007+
* when the notification first arrives.
4008+
*/
4009+
ticker?: string;
4010+
4011+
/**
4012+
* When set to `false` or unset, the notification is automatically dismissed when
4013+
* the user clicks it in the panel. When set to `true`, the notification persists
4014+
* even when the user clicks it.
4015+
*/
4016+
sticky?: boolean;
4017+
4018+
/**
4019+
* For notifications that inform users about events with an absolute time reference, sets
4020+
* the time that the event in the notification occurred. Notifications
4021+
* in the panel are sorted by this time.
4022+
*/
4023+
eventTimestamp?: Date;
4024+
4025+
/**
4026+
* Sets whether or not this notification is relevant only to the current device.
4027+
* Some notifications can be bridged to other devices for remote display, such as
4028+
* a Wear OS watch. This hint can be set to recommend this notification not be bridged.
4029+
* See [Wear OS guides](https://developer.android.com/training/wearables/notifications/bridger#existing-method-of-preventing-bridging)
4030+
*/
4031+
localOnly?: boolean;
4032+
4033+
/**
4034+
* Sets the relative priority for this notification. Low-priority notifications
4035+
* may be hidden from the user in certain situations. Note this priority differs
4036+
* from `AndroidMessagePriority`. This priority is processed by the client after
4037+
* the message has been delivered. Whereas `AndroidMessagePriority` is an FCM concept
4038+
* that controls when the message is delivered.
4039+
*/
4040+
priority?: ('min' | 'low' | 'default' | 'high' | 'max');
4041+
4042+
/**
4043+
* Sets the vibration pattern to use. Pass in an array of milliseconds to
4044+
* turn the vibrator on or off. The first value indicates the duration to wait before
4045+
* turning the vibrator on. The next value indicates the duration to keep the
4046+
* vibrator on. Subsequent values alternate between duration to turn the vibrator
4047+
* off and to turn the vibrator on. If `vibrate_timings` is set and `default_vibrate_timings`
4048+
* is set to `true`, the default value is used instead of the user-specified `vibrate_timings`.
4049+
*/
4050+
vibrateTimingsMillis?: number[];
4051+
4052+
/**
4053+
* If set to `true`, use the Android framework's default vibrate pattern for the
4054+
* notification. Default values are specified in [`config.xml`](https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml).
4055+
* If `default_vibrate_timings` is set to `true` and `vibrate_timings` is also set,
4056+
* the default value is used instead of the user-specified `vibrate_timings`.
4057+
*/
4058+
defaultVibrateTimings?: boolean;
4059+
4060+
/**
4061+
* If set to `true`, use the Android framework's default sound for the notification.
4062+
* Default values are specified in [`config.xml`](https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml).
4063+
*/
4064+
defaultSound?: boolean;
4065+
4066+
/**
4067+
* Settings to control the notification's LED blinking rate and color if LED is
4068+
* available on the device. The total blinking time is controlled by the OS.
4069+
*/
4070+
lightSettings?: LightSettings;
4071+
4072+
/**
4073+
* If set to `true`, use the Android framework's default LED light settings
4074+
* for the notification. Default values are specified in [`config.xml`](https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml).
4075+
* If `default_light_settings` is set to `true` and `light_settings` is also set,
4076+
* the user-specified `light_settings` is used instead of the default value.
4077+
*/
4078+
defaultLightSettings?: boolean;
4079+
4080+
/**
4081+
* Sets the visibility of the notification. Must be either `private`, `public`,
4082+
* or `secret`. If unspecified, defaults to `private`.
4083+
*/
4084+
visibility?: ('private' | 'public' | 'secret');
4085+
4086+
/**
4087+
* Sets the number of items this notification represents. May be displayed as a
4088+
* badge count for Launchers that support badging. See [`NotificationBadge`(https://developer.android.com/training/notify-user/badges).
4089+
* For example, this might be useful if you're using just one notification to
4090+
* represent multiple new messages but you want the count here to represent
4091+
* the number of total new messages. If zero or unspecified, systems
4092+
* that support badging use the default, which is to increment a number
4093+
* displayed on the long-press menu each time a new notification arrives.
4094+
*/
4095+
notificationCount?: number;
4096+
}
4097+
4098+
/**
4099+
* Represents settings to control notification LED that can be included in
4100+
* {@link admin.messaging.AndroidNotification}.
4101+
*/
4102+
interface LightSettings {
4103+
/**
4104+
* Required. Sets color of the LED in `#rrggbb` or `#rrggbbaa` format.
4105+
*/
4106+
color: string;
4107+
4108+
/**
4109+
* Required. Along with `light_off_duration`, defines the blink rate of LED flashes.
4110+
*/
4111+
lightOnDurationMillis: number;
4112+
4113+
/**
4114+
* Required. Along with `light_on_duration`, defines the blink rate of LED flashes.
4115+
*/
4116+
lightOffDurationMillis: number;
40034117
}
40044118

40054119
/**

src/messaging/messaging-types.ts

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ export interface AndroidNotification {
171171
titleLocKey?: string;
172172
titleLocArgs?: string[];
173173
channelId?: string;
174+
ticker?: string;
175+
sticky?: boolean;
176+
eventTimestamp?: Date;
177+
localOnly?: boolean;
178+
priority?: ('min' | 'low' | 'default' | 'high' | 'max');
179+
vibrateTimingsMillis?: number[];
180+
defaultVibrateTimings?: boolean;
181+
defaultSound?: boolean;
182+
lightSettings?: LightSettings;
183+
defaultLightSettings?: boolean;
184+
visibility?: ('private' | 'public' | 'secret');
185+
notificationCount?: number;
186+
}
187+
188+
export interface LightSettings {
189+
color: string;
190+
lightOnDurationMillis: number;
191+
lightOffDurationMillis: number;
174192
}
175193

176194
export interface AndroidFcmOptions {
@@ -628,18 +646,7 @@ function validateAndroidConfig(config: AndroidConfig) {
628646
MessagingClientErrorCode.INVALID_PAYLOAD,
629647
'TTL must be a non-negative duration in milliseconds');
630648
}
631-
const seconds = Math.floor(config.ttl / 1000);
632-
const nanos = (config.ttl - seconds * 1000) * 1000000;
633-
let duration: string;
634-
if (nanos > 0) {
635-
let nanoString = nanos.toString();
636-
while (nanoString.length < 9) {
637-
nanoString = '0' + nanoString;
638-
}
639-
duration = `${seconds}.${nanoString}s`;
640-
} else {
641-
duration = `${seconds}s`;
642-
}
649+
const duration: string = transformMillisecondsToSecondsString(config.ttl);
643650
(config as any).ttl = duration;
644651
}
645652
validateStringMap(config.data, 'android.data');
@@ -691,6 +698,47 @@ function validateAndroidNotification(notification: AndroidNotification) {
691698
'android.notification.imageUrl must be a valid URL string');
692699
}
693700

701+
if (typeof notification.eventTimestamp !== 'undefined') {
702+
if (!(notification.eventTimestamp instanceof Date)) {
703+
throw new FirebaseMessagingError(
704+
MessagingClientErrorCode.INVALID_PAYLOAD, 'android.notification.eventTimestamp must be a valid `Date` object');
705+
}
706+
// Convert timestamp to RFC3339 UTC "Zulu" format, example "2014-10-02T15:01:23.045123456Z"
707+
const zuluTimestamp = notification.eventTimestamp.toISOString();
708+
(notification as any).eventTimestamp = zuluTimestamp;
709+
}
710+
711+
if (typeof notification.vibrateTimingsMillis !== 'undefined') {
712+
if (!validator.isNonEmptyArray(notification.vibrateTimingsMillis)) {
713+
throw new FirebaseMessagingError(
714+
MessagingClientErrorCode.INVALID_PAYLOAD,
715+
'android.notification.vibrateTimingsMillis must be a non-empty array of numbers');
716+
}
717+
const vibrateTimings: string[] = [];
718+
notification.vibrateTimingsMillis.forEach((value) => {
719+
if (!validator.isNumber(value) || value < 0) {
720+
throw new FirebaseMessagingError(
721+
MessagingClientErrorCode.INVALID_PAYLOAD,
722+
'android.notification.vibrateTimingsMillis must be non-negative durations in milliseconds');
723+
}
724+
const duration = transformMillisecondsToSecondsString(value);
725+
vibrateTimings.push(duration);
726+
});
727+
(notification as any).vibrateTimingsMillis = vibrateTimings;
728+
}
729+
730+
if (typeof notification.priority !== 'undefined') {
731+
const priority = 'PRIORITY_' + notification.priority.toUpperCase();
732+
(notification as any).priority = priority;
733+
}
734+
735+
if (typeof notification.visibility !== 'undefined') {
736+
const visibility = notification.visibility.toUpperCase();
737+
(notification as any).visibility = visibility;
738+
}
739+
740+
validateLightSettings(notification.lightSettings);
741+
694742
const propertyMappings = {
695743
clickAction: 'click_action',
696744
bodyLocKey: 'body_loc_key',
@@ -699,10 +747,73 @@ function validateAndroidNotification(notification: AndroidNotification) {
699747
titleLocArgs: 'title_loc_args',
700748
channelId: 'channel_id',
701749
imageUrl: 'image',
750+
eventTimestamp: 'event_time',
751+
localOnly: 'local_only',
752+
priority: 'notification_priority',
753+
vibrateTimingsMillis: 'vibrate_timings',
754+
defaultVibrateTimings: 'default_vibrate_timings',
755+
defaultSound: 'default_sound',
756+
lightSettings: 'light_settings',
757+
defaultLightSettings: 'default_light_settings',
758+
notificationCount: 'notification_count',
702759
};
703760
renameProperties(notification, propertyMappings);
704761
}
705762

763+
/**
764+
* Checks if the given LightSettings object is valid. The object must have valid color and
765+
* light on/off duration parameters. If successful, transforms the input object by renaming
766+
* keys to valid Android keys.
767+
*
768+
* @param {LightSettings} lightSettings An object to be validated.
769+
*/
770+
function validateLightSettings(lightSettings: LightSettings) {
771+
if (typeof lightSettings === 'undefined') {
772+
return;
773+
} else if (!validator.isNonNullObject(lightSettings)) {
774+
throw new FirebaseMessagingError(
775+
MessagingClientErrorCode.INVALID_PAYLOAD, 'android.notification.lightSettings must be a non-null object');
776+
}
777+
778+
if (!validator.isNumber(lightSettings.lightOnDurationMillis) || lightSettings.lightOnDurationMillis < 0) {
779+
throw new FirebaseMessagingError(
780+
MessagingClientErrorCode.INVALID_PAYLOAD,
781+
'android.notification.lightSettings.lightOnDurationMillis must be a non-negative duration in milliseconds');
782+
}
783+
const durationOn = transformMillisecondsToSecondsString(lightSettings.lightOnDurationMillis);
784+
(lightSettings as any).lightOnDurationMillis = durationOn;
785+
786+
if (!validator.isNumber(lightSettings.lightOffDurationMillis) || lightSettings.lightOffDurationMillis < 0) {
787+
throw new FirebaseMessagingError(
788+
MessagingClientErrorCode.INVALID_PAYLOAD,
789+
'android.notification.lightSettings.lightOffDurationMillis must be a non-negative duration in milliseconds');
790+
}
791+
const durationOff = transformMillisecondsToSecondsString(lightSettings.lightOffDurationMillis);
792+
(lightSettings as any).lightOffDurationMillis = durationOff;
793+
794+
if (!validator.isString(lightSettings.color) ||
795+
(!/^#[0-9a-fA-F]{6}$/.test(lightSettings.color) && !/^#[0-9a-fA-F]{8}$/.test(lightSettings.color))) {
796+
throw new FirebaseMessagingError(
797+
MessagingClientErrorCode.INVALID_PAYLOAD,
798+
'android.notification.lightSettings.color must be in the form #RRGGBB or #RRGGBBAA format');
799+
}
800+
const colorString = lightSettings.color.length === 7 ? lightSettings.color + 'FF' : lightSettings.color;
801+
const rgb = /^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/i.exec(colorString);
802+
const color = {
803+
red: parseInt(rgb[1], 16) / 255.0,
804+
green: parseInt(rgb[2], 16) / 255.0,
805+
blue: parseInt(rgb[3], 16) / 255.0,
806+
alpha: parseInt(rgb[4], 16) / 255.0,
807+
};
808+
(lightSettings as any).color = color;
809+
810+
const propertyMappings = {
811+
lightOnDurationMillis: 'light_on_duration',
812+
lightOffDurationMillis: 'light_off_duration',
813+
};
814+
renameProperties(lightSettings, propertyMappings);
815+
}
816+
706817
/**
707818
* Checks if the given AndroidFcmOptions object is valid.
708819
*
@@ -721,3 +832,28 @@ function validateAndroidFcmOptions(fcmOptions: AndroidFcmOptions) {
721832
MessagingClientErrorCode.INVALID_PAYLOAD, 'analyticsLabel must be a string value');
722833
}
723834
}
835+
836+
/**
837+
* Transforms milliseconds to the format expected by FCM service.
838+
* Returns the duration in seconds with up to nine fractional
839+
* digits, terminated by 's'. Example: "3.5s".
840+
*
841+
* @param {number} milliseconds The duration in milliseconds.
842+
* @return {string} The resulting formatted string in seconds with up to nine fractional
843+
* digits, terminated by 's'.
844+
*/
845+
function transformMillisecondsToSecondsString(milliseconds: number): string {
846+
let duration: string;
847+
const seconds = Math.floor(milliseconds / 1000);
848+
const nanos = (milliseconds - seconds * 1000) * 1000000;
849+
if (nanos > 0) {
850+
let nanoString = nanos.toString();
851+
while (nanoString.length < 9) {
852+
nanoString = '0' + nanoString;
853+
}
854+
duration = `${seconds}.${nanoString}s`;
855+
} else {
856+
duration = `${seconds}s`;
857+
}
858+
return duration;
859+
}

test/integration/messaging.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ const message: admin.messaging.Message = {
4949
},
5050
android: {
5151
restrictedPackageName: 'com.google.firebase.testing',
52+
notification: {
53+
title: 'test.title',
54+
ticker: 'test.ticker',
55+
sticky: true,
56+
visibility: 'private',
57+
eventTimestamp: new Date(),
58+
localOnly: true,
59+
priority: 'high',
60+
vibrateTimingsMillis: [100, 50, 250],
61+
defaultVibrateTimings: false,
62+
defaultSound: true,
63+
lightSettings: {
64+
color: '#AABBCC55',
65+
lightOnDurationMillis: 200,
66+
lightOffDurationMillis: 300,
67+
},
68+
defaultLightSettings: false,
69+
notificationCount: 1,
70+
},
5271
},
5372
apns: {
5473
payload: {

0 commit comments

Comments
 (0)