From 2452e3d10c39fd8b72093741ab56dc235d5764a7 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 14 Jul 2026 14:30:35 -0400 Subject: [PATCH 1/3] feat(fcm): Add support for AndroidConfigV2 --- .../AndroidBackgroundSyncMessage.java | 49 ++ .../firebase/messaging/AndroidConfig.java | 3 + .../firebase/messaging/AndroidConfigV2.java | 274 ++++++++ .../messaging/AndroidNotification.java | 20 +- .../messaging/AndroidNotificationV2.java | 628 ++++++++++++++++++ .../messaging/AndroidRemoteNotification.java | 105 +++ .../google/firebase/messaging/Message.java | 36 + .../firebase/messaging/MessageTest.java | 215 ++++++ 8 files changed, 1321 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/google/firebase/messaging/AndroidBackgroundSyncMessage.java create mode 100644 src/main/java/com/google/firebase/messaging/AndroidConfigV2.java create mode 100644 src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java create mode 100644 src/main/java/com/google/firebase/messaging/AndroidRemoteNotification.java diff --git a/src/main/java/com/google/firebase/messaging/AndroidBackgroundSyncMessage.java b/src/main/java/com/google/firebase/messaging/AndroidBackgroundSyncMessage.java new file mode 100644 index 000000000..1f8780d65 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/AndroidBackgroundSyncMessage.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.messaging; + +/** + * Represents a background sync message in Android v2 config. + * Instances of this class are thread-safe and immutable. + */ +public final class AndroidBackgroundSyncMessage { + + private AndroidBackgroundSyncMessage(Builder builder) {} + + /** + * Creates a new {@link AndroidBackgroundSyncMessage.Builder}. + * + * @return An {@link AndroidBackgroundSyncMessage.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Builder() {} + + /** + * Creates a new {@link AndroidBackgroundSyncMessage} instance from the parameters set + * on this builder. + * + * @return A new {@link AndroidBackgroundSyncMessage} instance. + */ + public AndroidBackgroundSyncMessage build() { + return new AndroidBackgroundSyncMessage(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index 22f591680..2069d831d 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -28,7 +28,10 @@ /** * Represents the Android-specific options that can be included in a {@link Message}. * Instances of this class are thread-safe and immutable. + * + * @deprecated Use {@link AndroidConfigV2} instead. */ +@Deprecated public class AndroidConfig { @Key("collapse_key") diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfigV2.java b/src/main/java/com/google/firebase/messaging/AndroidConfigV2.java new file mode 100644 index 000000000..9af65bc2c --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/AndroidConfigV2.java @@ -0,0 +1,274 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.messaging; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.util.Key; +import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.Booleans; +import com.google.firebase.internal.NonNull; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents the Android-specific v2 options that can be included in a {@link Message}. + * Instances of this class are thread-safe and immutable. + */ +public final class AndroidConfigV2 { + + @Key("collapse_key") + private final String collapseKey; + + @Key("ttl") + private final String ttl; + + @Key("restricted_package_name") + private final String restrictedPackageName; + + @Key("data") + private final Map data; + + @Key("remote_notification") + private final AndroidRemoteNotification remoteNotification; + + @Key("background_sync") + private final AndroidBackgroundSyncMessage backgroundSync; + + @Key("fcm_options") + private final AndroidFcmOptions fcmOptions; + + @Key("direct_boot_ok") + private final Boolean directBootOk; + + @Key("bandwidth_constrained_ok") + private final Boolean bandwidthConstrainedOk; + + @Key("restricted_satellite_ok") + private final Boolean restrictedSatelliteOk; + + private AndroidConfigV2(Builder builder) { + this.collapseKey = builder.collapseKey; + if (builder.ttl != null) { + checkArgument(!builder.ttl.isNegative(), "ttl must not be negative"); + long seconds = builder.ttl.getSeconds(); + long subsecondNanos = builder.ttl.getNano(); + if (subsecondNanos > 0) { + this.ttl = String.format("%d.%09ds", seconds, subsecondNanos); + } else { + this.ttl = String.format("%ds", seconds); + } + } else { + this.ttl = null; + } + this.restrictedPackageName = builder.restrictedPackageName; + this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); + + int targets = Booleans.countTrue( + builder.remoteNotification != null, + builder.backgroundSync != null + ); + checkArgument(targets == 1, + "Exactly one of remoteNotification or backgroundSync must be specified"); + this.remoteNotification = builder.remoteNotification; + this.backgroundSync = builder.backgroundSync; + + this.fcmOptions = builder.fcmOptions; + this.directBootOk = builder.directBootOk; + this.bandwidthConstrainedOk = builder.bandwidthConstrainedOk; + this.restrictedSatelliteOk = builder.restrictedSatelliteOk; + } + + /** + * Creates a new {@link AndroidConfigV2.Builder}. + * + * @return An {@link AndroidConfigV2.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String collapseKey; + private Duration ttl; + private String restrictedPackageName; + private final Map data = new HashMap<>(); + private AndroidRemoteNotification remoteNotification; + private AndroidBackgroundSyncMessage backgroundSync; + private AndroidFcmOptions fcmOptions; + private Boolean directBootOk; + private Boolean bandwidthConstrainedOk; + private Boolean restrictedSatelliteOk; + + private Builder() {} + + /** + * Sets a collapse key for the message. The 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 4 different collapse keys may be active at any given time. + * + *

By default, the collapse key is the app package name registered in + * the Firebase console.

+ * + * @param collapseKey A collapse key string. + * @return This builder. + */ + public Builder setCollapseKey(String collapseKey) { + this.collapseKey = collapseKey; + return this; + } + + /** + * Sets the time-to-live duration of the message. + * + * @param ttl Time-to-live duration. + * @return This builder. + */ + public Builder setTtl(Duration ttl) { + this.ttl = ttl; + return this; + } + + /** + * Sets the package name of the application where the registration tokens must match in order + * to receive the message. + * + * @param restrictedPackageName A package name string. + * @return This builder. + */ + public Builder setRestrictedPackageName(String restrictedPackageName) { + this.restrictedPackageName = restrictedPackageName; + return this; + } + + /** + * Adds the given key-value pair to the message as a data field. Key and the value may not be + * null. When set, overrides any data fields set on the top-level {@link Message} via + * {@link Message.Builder#putData(String, String)} and {@link Message.Builder#putAllData(Map)}. + * + * @param key Name of the data field. Must not be null. + * @param value Value of the data field. Must not be null. + * @return This builder. + */ + public Builder putData(@NonNull String key, @NonNull String value) { + this.data.put(key, value); + return this; + } + + /** + * Adds all the key-value pairs in the given map to the message as data fields. None of the + * keys and values may be null. When set, overrides any data fields set on the top-level + * {@link Message} via {@link Message.Builder#putData(String, String)} and + * {@link Message.Builder#putAllData(Map)}. + * + * @param map A non-null map of data fields. Map must not contain null keys or values. + * @return This builder. + */ + public Builder putAllData(@NonNull Map map) { + this.data.putAll(map); + return this; + } + + /** + * Sets the remote notification payload. + * + *

Exactly one of remote notification or background sync must be specified. This setting is + * mutually exclusive with {@link #setBackgroundSync(AndroidBackgroundSyncMessage)}.

+ * + * @param remoteNotification Remote notification config. + * @return This builder. + */ + public Builder setRemoteNotification(AndroidRemoteNotification remoteNotification) { + this.remoteNotification = remoteNotification; + return this; + } + + /** + * Sets the background sync payload. + * + *

Exactly one of remote notification or background sync must be specified. This setting is + * mutually exclusive with {@link #setRemoteNotification(AndroidRemoteNotification)}.

+ * + * @param backgroundSync Background sync config. + * @return This builder. + */ + public Builder setBackgroundSync(AndroidBackgroundSyncMessage backgroundSync) { + this.backgroundSync = backgroundSync; + return this; + } + + /** + * Sets the {@link AndroidFcmOptions}, which overrides values set in the {@link FcmOptions} + * for Android messages. + * + * @param fcmOptions FCM options. + * @return This builder. + */ + public Builder setFcmOptions(AndroidFcmOptions fcmOptions) { + this.fcmOptions = fcmOptions; + return this; + } + + /** + * Sets the {@code direct_boot_ok} flag. If set to true, messages are delivered to + * the app while the device is in direct boot mode. + * + * @param directBootOk True to allow delivery in direct boot mode. + * @return This builder. + */ + public Builder setDirectBootOk(boolean directBootOk) { + this.directBootOk = directBootOk; + return this; + } + + /** + * Sets the {@code bandwidth_constrained_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a bandwidth constrained network. + * + * @param bandwidthConstrainedOk True to allow delivery on bandwidth constrained networks. + * @return This builder. + */ + public Builder setBandwidthConstrainedOk(boolean bandwidthConstrainedOk) { + this.bandwidthConstrainedOk = bandwidthConstrainedOk; + return this; + } + + /** + * Sets the {@code restricted_satellite_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a restricted satellite network. + * + * @param restrictedSatelliteOk True to allow delivery on restricted satellite networks. + * @return This builder. + */ + public Builder setRestrictedSatelliteOk(boolean restrictedSatelliteOk) { + this.restrictedSatelliteOk = restrictedSatelliteOk; + return this; + } + + /** + * Creates a new {@link AndroidConfigV2} instance from the parameters set on this builder. + * + * @return A new {@link AndroidConfigV2} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public AndroidConfigV2 build() { + return new AndroidConfigV2(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotification.java b/src/main/java/com/google/firebase/messaging/AndroidNotification.java index bcd5cc962..4102df7cd 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidNotification.java +++ b/src/main/java/com/google/firebase/messaging/AndroidNotification.java @@ -34,7 +34,10 @@ /** * Represents the Android-specific notification options that can be included in a {@link Message}. * Instances of this class are thread-safe and immutable. + * + * @deprecated Use {@link AndroidNotificationV2} instead. */ +@Deprecated public class AndroidNotification { @Key("title") @@ -534,7 +537,7 @@ public Builder setVibrateTimingsInMillis(long[] vibrateTimingsInMillis) { } /** - * Sets the whether to use the default vibration timings. If set to true, use the Android + * Sets whether to use the default vibration timings. If set to true, use the Android * framework's default vibrate pattern for the notification. Default values are specified * in {@code config.xml}. If {@code default_vibrate_timings} is set to true and * {@code vibrate_timings} is also set, the default value is used instead of the @@ -549,7 +552,7 @@ public Builder setDefaultVibrateTimings(boolean defaultVibrateTimings) { } /** - * Sets the whether to use the default sound. If set to true, use the Android framework's + * Sets whether to use the default sound. If set to true, use the Android framework's * default sound for the notification. Default values are specified in config.xml. * * @param defaultSound The flag indicating whether to use the default sound @@ -601,13 +604,12 @@ public Builder setVisibility(Visibility visibility) { /** * Sets the number of items this notification represents. May be displayed as a badge - * count for launchers that support badging. - * If not invoked then notification count is left unchanged. - * 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. + * count for launchers that support badging. If not invoked then notification count is left + * unchanged. 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. * * @param notificationCount Zero or positive value. Zero indicates leave unchanged. * @return This builder. diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java b/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java new file mode 100644 index 000000000..cbce3d204 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java @@ -0,0 +1,628 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.messaging; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +/** + * Represents the Android-specific notification options that can be included in a + * {@link AndroidRemoteNotification}. + * Instances of this class are thread-safe and immutable. + */ +public final class AndroidNotificationV2 { + + @Key("title") + private final String title; + + @Key("body") + private final String body; + + @Key("icon") + private final String icon; + + @Key("color") + private final String color; + + @Key("sound") + private final String sound; + + @Key("tag") + private final String tag; + + @Key("click_action") + private final String clickAction; + + @Key("body_loc_key") + private final String bodyLocKey; + + @Key("body_loc_args") + private final List bodyLocArgs; + + @Key("title_loc_key") + private final String titleLocKey; + + @Key("title_loc_args") + private final List titleLocArgs; + + @Key("channel_id") + private final String channelId; + + @Key("image") + private final String image; + + @Key("ticker") + private final String ticker; + + @Key("sticky") + private final Boolean sticky; + + @Key("event_time") + private final String eventTime; + + @Key("local_only") + private final Boolean localOnly; + + @Key("notification_priority") + private final String priority; + + @Key("vibrate_timings") + private final List vibrateTimings; + + @Key("default_vibrate_timings") + private final Boolean defaultVibrateTimings; + + @Key("default_sound") + private final Boolean defaultSound; + + @Key("light_settings") + private final LightSettings lightSettings; + + @Key("default_light_settings") + private final Boolean defaultLightSettings; + + @Key("visibility") + private final String visibility; + + @Key("notification_count") + private final Integer notificationCount; + + @Key("id") + private final Integer id; + + private static final Map PRIORITY_MAP = + ImmutableMap.builder() + .put(NotificationPriority.MIN, "PRIORITY_MIN") + .put(NotificationPriority.LOW, "PRIORITY_LOW") + .put(NotificationPriority.DEFAULT, "PRIORITY_DEFAULT") + .put(NotificationPriority.HIGH, "PRIORITY_HIGH") + .put(NotificationPriority.MAX, "PRIORITY_MAX") + .build(); + + private AndroidNotificationV2(Builder builder) { + this.title = builder.title; + this.body = builder.body; + this.icon = builder.icon; + if (builder.color != null) { + checkArgument(builder.color.matches("^#[0-9a-fA-F]{6}$"), + "color must be in the form #RRGGBB"); + } + this.color = builder.color; + this.sound = builder.sound; + this.tag = builder.tag; + this.clickAction = builder.clickAction; + this.bodyLocKey = builder.bodyLocKey; + if (!builder.bodyLocArgs.isEmpty()) { + checkArgument(!Strings.isNullOrEmpty(builder.bodyLocKey), + "bodyLocKey is required when specifying bodyLocArgs"); + this.bodyLocArgs = ImmutableList.copyOf(builder.bodyLocArgs); + } else { + this.bodyLocArgs = null; + } + + this.titleLocKey = builder.titleLocKey; + if (!builder.titleLocArgs.isEmpty()) { + checkArgument(!Strings.isNullOrEmpty(builder.titleLocKey), + "titleLocKey is required when specifying titleLocArgs"); + this.titleLocArgs = ImmutableList.copyOf(builder.titleLocArgs); + } else { + this.titleLocArgs = null; + } + this.channelId = builder.channelId; + this.image = builder.image; + this.ticker = builder.ticker; + this.sticky = builder.sticky; + this.eventTime = builder.eventTime; + this.localOnly = builder.localOnly; + this.priority = builder.priority != null ? PRIORITY_MAP.get(builder.priority) : null; + + if (!builder.vibrateTimings.isEmpty()) { + this.vibrateTimings = ImmutableList.copyOf(builder.vibrateTimings); + } else { + this.vibrateTimings = null; + } + this.defaultVibrateTimings = builder.defaultVibrateTimings; + this.defaultSound = builder.defaultSound; + this.lightSettings = builder.lightSettings; + this.defaultLightSettings = builder.defaultLightSettings; + this.visibility = (builder.visibility != null + && builder.visibility != Visibility.UNSPECIFIED) + ? builder.visibility.name().toLowerCase() : null; + if (builder.notificationCount != null) { + checkArgument(builder.notificationCount >= 0, + "notificationCount if specified must be zero or positive valued"); + } + this.notificationCount = builder.notificationCount; + this.id = builder.id; + } + + /** + * Priority levels for {@link AndroidNotificationV2}. + */ + public enum NotificationPriority { + UNSPECIFIED, + MIN, + LOW, + DEFAULT, + HIGH, + MAX + } + + /** + * Visibility settings for {@link AndroidNotificationV2}. + */ + public enum Visibility { + UNSPECIFIED, + PRIVATE, + PUBLIC, + SECRET + } + + /** + * Creates a new {@link AndroidNotificationV2.Builder}. + * + * @return An {@link AndroidNotificationV2.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String title; + private String body; + private String icon; + private String color; + private String sound; + private String tag; + private String clickAction; + private String bodyLocKey; + private List bodyLocArgs = new ArrayList<>(); + private String titleLocKey; + private List titleLocArgs = new ArrayList<>(); + private String channelId; + private String image; + private Integer notificationCount; + private String ticker; + private Boolean sticky; + private String eventTime; + private Boolean localOnly; + private NotificationPriority priority; + private List vibrateTimings = new ArrayList<>(); + private Boolean defaultVibrateTimings; + private Boolean defaultSound; + private LightSettings lightSettings; + private Boolean defaultLightSettings; + private Visibility visibility; + private Integer id; + + private Builder() {} + + /** + * Sets the title of the notification. + * + * @param title Title of the notification. + * @return This builder. + */ + public Builder setTitle(String title) { + this.title = title; + return this; + } + + /** + * Sets the body of the notification. + * + * @param body Body of the notification. + * @return This builder. + */ + public Builder setBody(String body) { + this.body = body; + return this; + } + + /** + * Sets the icon of the notification. + * + * @param icon Icon resource for the notification. + * @return This builder. + */ + public Builder setIcon(String icon) { + this.icon = icon; + return this; + } + + /** + * Sets the notification icon color. + * + * @param color Color specified in the {@code #rrggbb} format. + * @return This builder. + */ + public Builder setColor(String color) { + this.color = color; + return this; + } + + /** + * Sets the sound to be played when the device receives the notification. + * + * @param sound File name of the sound resource or "default". + * @return This builder. + */ + public Builder setSound(String sound) { + this.sound = sound; + return this; + } + + /** + * Sets the notification tag. This is an identifier used to replace existing notifications in + * the notification drawer. If not specified, each request creates a new notification. + * + * @param tag Notification tag. + * @return This builder. + */ + public Builder setTag(String tag) { + this.tag = tag; + return this; + } + + /** + * Sets the 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. + * + * @param clickAction Click action name. + * @return This builder. + */ + public Builder setClickAction(String clickAction) { + this.clickAction = clickAction; + return this; + } + + /** + * Sets the key of the body string in the app's string resources to use to localize the body + * text. + * + * @param bodyLocKey Resource key string. + * @return This builder. + */ + public Builder setBodyLocalizationKey(String bodyLocKey) { + this.bodyLocKey = bodyLocKey; + return this; + } + + /** + * Adds a resource key string that will be used in place of the format specifiers in + * {@code bodyLocKey}. + * + * @param arg Resource key string. + * @return This builder. + */ + public Builder addBodyLocalizationArg(String arg) { + this.bodyLocArgs.add(arg); + return this; + } + + /** + * Adds a list of resource keys that will be used in place of the format specifiers in + * {@code bodyLocKey}. + * + * @param args List of resource key strings. + * @return This builder. + */ + public Builder addAllBodyLocalizationArgs(@NonNull List args) { + this.bodyLocArgs.addAll(args); + return this; + } + + /** + * Sets the key of the title string in the app's string resources to use to localize the title + * text. + * + * @param titleLocKey Resource key string. + * @return This builder. + */ + public Builder setTitleLocalizationKey(String titleLocKey) { + this.titleLocKey = titleLocKey; + return this; + } + + /** + * Adds a resource key string that will be used in place of the format specifiers in + * {@code titleLocKey}. + * + * @param arg Resource key string. + * @return This builder. + */ + public Builder addTitleLocalizationArg(String arg) { + this.titleLocArgs.add(arg); + return this; + } + + /** + * Adds a list of resource keys that will be used in place of the format specifiers in + * {@code titleLocKey}. + * + * @param args List of resource key strings. + * @return This builder. + */ + public Builder addAllTitleLocalizationArgs(@NonNull List args) { + this.titleLocArgs.addAll(args); + return this; + } + + /** + * Sets 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 is 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. + * + * @param channelId The notification's channel ID. + * @return This builder. + */ + public Builder setChannelId(String channelId) { + this.channelId = channelId; + return this; + } + + /** + * Sets the URL of the image that is going to be displayed in the notification. + * + * @param imageUrl URL of the image that is going to be displayed in the notification. + * @return This builder. + */ + public Builder setImage(String imageUrl) { + this.image = imageUrl; + return this; + } + + /** + * 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. + * + * @param ticker Ticker name. + * @return This builder. + */ + public Builder setTicker(String ticker) { + this.ticker = ticker; + return this; + } + + /** + * Sets the sticky flag. 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. + * + * @param sticky The sticky flag. + * @return This builder. + */ + public Builder setSticky(boolean sticky) { + this.sticky = sticky; + return this; + } + + /** + * For notifications that inform users about events with an absolute time reference, sets + * the time that the event in the notification occurred in milliseconds. Notifications + * in the panel are sorted by this time. The time is formatted in RFC3339 UTC "Zulu" + * format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z". Note that + * since the time is in milliseconds, the last section of the time representation always + * has 6 leading zeros. + * + * @param eventTimeInMillis The event time in milliseconds + * @return This builder. + */ + public Builder setEventTimeInMillis(long eventTimeInMillis) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + this.eventTime = dateFormat.format(new Date(eventTimeInMillis)); + return this; + } + + /** + * 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. + * + * @param localOnly The "local only" flag. + * @return This builder. + */ + public Builder setLocalOnly(boolean localOnly) { + this.localOnly = localOnly; + return this; + } + + /** + * Sets the relative priority for this notification. Priority is an indication of how much of + * the user's attention should be consumed by this notification. Low-priority notifications + * may be hidden from the user in certain situations, while the user might be interrupted + * for a higher-priority notification. + * + * @param priority The priority value, one of the values in {MIN, LOW, DEFAULT, HIGH, MAX}. + * @return This builder. + */ + public Builder setNotificationPriority(NotificationPriority priority) { + this.priority = priority; + return this; + } + + /** + * Sets a list of vibration timings in milliseconds in the array to use. The first value in the + * array 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 {@code vibrate_timings} + * is set and {@code default_vibrate_timings} is set to true, the default value is used instead + * of the user-specified {@code vibrate_timings}. + * A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * + * @param vibrateTimingsInMillis List of vibration time in milliseconds + * @return This builder. + */ + public Builder setVibrateTimingsInMillis(long[] vibrateTimingsInMillis) { + List list = new ArrayList<>(); + for (long value : vibrateTimingsInMillis) { + checkArgument(value >= 0, "elements in vibrateTimingsInMillis must not be negative"); + long seconds = TimeUnit.MILLISECONDS.toSeconds(value); + long subsecondNanos = TimeUnit.MILLISECONDS.toNanos(value - seconds * 1000L); + if (subsecondNanos > 0) { + list.add(String.format("%d.%09ds", seconds, subsecondNanos)); + } else { + list.add(String.format("%ds", seconds)); + } + } + this.vibrateTimings = ImmutableList.copyOf(list); + return this; + } + + /** + * Sets whether to use the default vibration timings. If set to true, use the Android + * framework's default vibrate pattern for the notification. Default values are specified + * in {@code config.xml}. If {@code default_vibrate_timings} is set to true and + * {@code vibrate_timings} is also set, the default value is used instead of the + * user-specified {@code vibrate_timings}. + * + * @param defaultVibrateTimings The flag indicating whether to use the default vibration timings + * @return This builder. + */ + public Builder setDefaultVibrateTimings(boolean defaultVibrateTimings) { + this.defaultVibrateTimings = defaultVibrateTimings; + return this; + } + + /** + * Sets whether to use the default sound. If set to true, use the Android framework's + * default sound for the notification. Default values are specified in config.xml. + * + * @param defaultSound The flag indicating whether to use the default sound + * @return This builder. + */ + public Builder setDefaultSound(boolean defaultSound) { + this.defaultSound = defaultSound; + return this; + } + + /** + * Sets the 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. + * + * @param lightSettings The light settings to use + * @return This builder. + */ + public Builder setLightSettings(LightSettings lightSettings) { + this.lightSettings = lightSettings; + return this; + } + + /** + * Sets whether to use the default light settings. If set to true, use the Android + * framework's default LED light settings for the notification. Default values are + * specified in config.xml. If {@code default_light_settings} is set to true and + * {@code light_settings} is also set, the user-specified {@code light_settings} is used + * instead of the default value. + * + * @param defaultLightSettings The flag indicating whether to use the default light + * settings + * @return This builder. + */ + public Builder setDefaultLightSettings(boolean defaultLightSettings) { + this.defaultLightSettings = defaultLightSettings; + return this; + } + + /** + * Sets the visibility of this notification. + * + * @param visibility The visibility value. One of the values in {PRIVATE, PUBLIC, SECRET}. + * @return This builder. + */ + public Builder setVisibility(Visibility visibility) { + this.visibility = visibility; + return this; + } + + /** + * Sets the number of items this notification represents. May be displayed as a badge + * count for launchers that support badging. If not invoked, the notification count is left + * unchanged. 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. + * + * @param notificationCount Zero or positive value. Zero indicates leave unchanged. + * @return This builder. + */ + public Builder setNotificationCount(int notificationCount) { + this.notificationCount = notificationCount; + return this; + } + + /** + * Sets the notification ID. If specified, and a notification with the same tag/ID is already + * active, the new notification will replace the existing one in the notification drawer. If + * not specified, a new notification is created. + * + * @param id Notification ID. + * @return This builder. + */ + public Builder setId(Integer id) { + this.id = id; + return this; + } + + /** + * Creates a new {@link AndroidNotificationV2} instance from the parameters set on this + * builder. + * + * @return A new {@link AndroidNotificationV2} instance. + * @throws IllegalArgumentException If any of the parameters set on the builder are invalid. + */ + public AndroidNotificationV2 build() { + return new AndroidNotificationV2(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/AndroidRemoteNotification.java b/src/main/java/com/google/firebase/messaging/AndroidRemoteNotification.java new file mode 100644 index 000000000..27b77c54f --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/AndroidRemoteNotification.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.messaging; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.util.Key; +import com.google.firebase.internal.NonNull; + +/** + * Represents a remote notification in Android v2 config. + * Instances of this class are thread-safe and immutable. + */ +public final class AndroidRemoteNotification { + + @Key("mutable_content") + private final Boolean mutableContent; + + @Key("notification") + private final AndroidNotificationV2 notification; + + @Key("use_as_v1_data_message") + private final Boolean useAsV1DataMessage; + + private AndroidRemoteNotification(Builder builder) { + this.mutableContent = builder.mutableContent; + this.notification = checkNotNull( + builder.notification, "notification must not be null"); + this.useAsV1DataMessage = builder.useAsV1DataMessage; + } + + /** + * Creates a new {@link AndroidRemoteNotification.Builder}. + * + * @return An {@link AndroidRemoteNotification.Builder} instance. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Boolean mutableContent; + private AndroidNotificationV2 notification; + private Boolean useAsV1DataMessage; + + private Builder() {} + + /** + * Sets the mutable content flag. + * + * @param mutableContent The mutable content flag. + * @return This builder. + */ + public Builder setMutableContent(boolean mutableContent) { + this.mutableContent = mutableContent; + return this; + } + + /** + * Sets the notification. + * + * @param notification Android notification v2 config. Must not be null. + * @return This builder. + */ + public Builder setNotification(@NonNull AndroidNotificationV2 notification) { + this.notification = notification; + return this; + } + + /** + * Sets whether to use this message as a v1 data message on legacy clients. + * + * @param useAsV1DataMessage The legacy data message flag. + * @return This builder. + */ + public Builder setUseAsV1DataMessage(boolean useAsV1DataMessage) { + this.useAsV1DataMessage = useAsV1DataMessage; + return this; + } + + /** + * Creates a new {@link AndroidRemoteNotification} instance from the parameters set + * on this builder. + * + * @return A new {@link AndroidRemoteNotification} instance. + */ + public AndroidRemoteNotification build() { + return new AndroidRemoteNotification(this); + } + } +} diff --git a/src/main/java/com/google/firebase/messaging/Message.java b/src/main/java/com/google/firebase/messaging/Message.java index da790fa7f..9614ad026 100644 --- a/src/main/java/com/google/firebase/messaging/Message.java +++ b/src/main/java/com/google/firebase/messaging/Message.java @@ -45,9 +45,16 @@ public class Message { @Key("notification") private final Notification notification; + /** + * @deprecated Use {@link #getAndroidConfigV2()} instead. + */ + @Deprecated @Key("android") private final AndroidConfig androidConfig; + @Key("androidV2") + private final AndroidConfigV2 androidConfigV2; + @Key("webpush") private final WebpushConfig webpushConfig; @@ -76,7 +83,13 @@ public class Message { private Message(Builder builder) { this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; + int androidConfigs = Booleans.countTrue( + builder.androidConfig != null, + builder.androidConfigV2 != null + ); + checkArgument(androidConfigs <= 1, "android and androidV2 are mutually exclusive"); this.androidConfig = builder.androidConfig; + this.androidConfigV2 = builder.androidConfigV2; this.webpushConfig = builder.webpushConfig; this.apnsConfig = builder.apnsConfig; int count = Booleans.countTrue( @@ -104,11 +117,20 @@ Notification getNotification() { return notification; } + /** + * @deprecated Use {@link #getAndroidConfigV2()} instead. + */ + @Deprecated @VisibleForTesting AndroidConfig getAndroidConfig() { return androidConfig; } + @VisibleForTesting + AndroidConfigV2 getAndroidConfigV2() { + return androidConfigV2; + } + @VisibleForTesting WebpushConfig getWebpushConfig() { return webpushConfig; @@ -183,6 +205,7 @@ public static class Builder { private final Map data = new HashMap<>(); private Notification notification; private AndroidConfig androidConfig; + private AndroidConfigV2 androidConfigV2; private WebpushConfig webpushConfig; private ApnsConfig apnsConfig; @Deprecated @@ -210,12 +233,25 @@ public Builder setNotification(Notification notification) { * * @param androidConfig An {@link AndroidConfig} instance. * @return This builder. + * @deprecated Use {@link #setAndroidConfigV2(AndroidConfigV2)} instead. */ + @Deprecated public Builder setAndroidConfig(AndroidConfig androidConfig) { this.androidConfig = androidConfig; return this; } + /** + * Sets the Android v2-specific information to be included in the message. + * + * @param androidConfigV2 An {@link AndroidConfigV2} instance. + * @return This builder. + */ + public Builder setAndroidConfigV2(AndroidConfigV2 androidConfigV2) { + this.androidConfigV2 = androidConfigV2; + return this; + } + /** * Sets the Webpush-specific information to be included in the message. * diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java index 0bca10df1..88ac19f33 100644 --- a/src/test/java/com/google/firebase/messaging/MessageTest.java +++ b/src/test/java/com/google/firebase/messaging/MessageTest.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1034,6 +1035,220 @@ public void testExtendedAndroidNotificationParameters() throws IOException { "topic", "test-topic", "notification", notification, "android", androidConfig), message); } + @Test + public void testAndroidV2BackgroundSync() throws IOException { + AndroidBackgroundSyncMessage backgroundSync = AndroidBackgroundSyncMessage.builder().build(); + AndroidConfigV2 config = AndroidConfigV2.builder() + .setCollapseKey("sync-key") + .setTtl(Duration.ofSeconds(10)) + .setBackgroundSync(backgroundSync) + .build(); + Message message = Message.builder() + .setAndroidConfigV2(config) + .setTopic("test-topic") + .build(); + Map expected = ImmutableMap.of( + "topic", "test-topic", + "androidV2", ImmutableMap.of( + "collapse_key", "sync-key", + "ttl", "10s", + "background_sync", ImmutableMap.of() + ) + ); + assertJsonEquals(expected, message); + } + + @Test + public void testAndroidV2RemoteNotification() throws IOException { + AndroidNotificationV2 notification = AndroidNotificationV2.builder() + .setTitle("title") + .setBody("body") + .setIcon("icon") + .setColor("#336699") + .setSound("sound") + .setTag("tag") + .setClickAction("click_action") + .setBodyLocalizationKey("body_loc_key") + .addBodyLocalizationArg("body_loc_arg") + .setTitleLocalizationKey("title_loc_key") + .addTitleLocalizationArg("title_loc_arg") + .setChannelId("channel_id") + .setImage(TEST_IMAGE_URL_ANDROID) + .setTicker("ticker") + .setSticky(true) + .setEventTimeInMillis(1783533600000L) + .setLocalOnly(true) + .setNotificationPriority(AndroidNotificationV2.NotificationPriority.HIGH) + .setDefaultSound(true) + .setDefaultVibrateTimings(false) + .setVibrateTimingsInMillis(new long[]{1000L, 500L}) + .setDefaultLightSettings(true) + .setLightSettings(LightSettings.builder() + .setColorFromString("#336699") + .setLightOnDurationInMillis(100) + .setLightOffDurationInMillis(200) + .build()) + .setVisibility(AndroidNotificationV2.Visibility.PUBLIC) + .setNotificationCount(5) + .setId(12345) + .build(); + + AndroidRemoteNotification remoteNotification = AndroidRemoteNotification.builder() + .setMutableContent(true) + .setNotification(notification) + .setUseAsV1DataMessage(true) + .build(); + + AndroidConfigV2 config = AndroidConfigV2.builder() + .setRemoteNotification(remoteNotification) + .build(); + + Message message = Message.builder() + .setAndroidConfigV2(config) + .setTopic("test-topic") + .build(); + + Map expected = ImmutableMap.of( + "topic", "test-topic", + "androidV2", ImmutableMap.of( + "remote_notification", ImmutableMap.of( + "mutable_content", true, + "use_as_v1_data_message", true, + "notification", ImmutableMap.builder() + .put("title", "title") + .put("body", "body") + .put("icon", "icon") + .put("color", "#336699") + .put("sound", "sound") + .put("tag", "tag") + .put("click_action", "click_action") + .put("body_loc_key", "body_loc_key") + .put("body_loc_args", ImmutableList.of("body_loc_arg")) + .put("title_loc_key", "title_loc_key") + .put("title_loc_args", ImmutableList.of("title_loc_arg")) + .put("channel_id", "channel_id") + .put("image", TEST_IMAGE_URL_ANDROID) + .put("ticker", "ticker") + .put("sticky", true) + .put("event_time", "2026-07-08T18:00:00.000000000Z") + .put("local_only", true) + .put("notification_priority", "PRIORITY_HIGH") + .put("default_sound", true) + .put("default_vibrate_timings", false) + .put("vibrate_timings", ImmutableList.of("1s", "0.500000000s")) + .put("default_light_settings", true) + .put("light_settings", ImmutableMap.of( + "color", ImmutableMap.of( + "red", new BigDecimal(new BigInteger("2"), 1), + "green", new BigDecimal(new BigInteger("4"), 1), + "blue", new BigDecimal(new BigInteger("6"), 1), + "alpha", new BigDecimal(new BigInteger("10"), 1) + ), + "light_on_duration", "0.100000000s", + "light_off_duration", "0.200000000s" + )) + .put("visibility", "public") + .put("notification_count", new BigDecimal(5)) + .put("id", new BigDecimal(12345)) + .build() + ) + ) + ); + + assertJsonEquals(expected, message); + } + + @Test + public void testMutualExclusivityAndroidV1AndV2() { + AndroidConfig v1 = AndroidConfig.builder().build(); + AndroidConfigV2 v2 = AndroidConfigV2.builder() + .setBackgroundSync(AndroidBackgroundSyncMessage.builder().build()) + .build(); + try { + Message.builder() + .setAndroidConfig(v1) + .setAndroidConfigV2(v2) + .setTopic("test-topic") + .build(); + fail("No error thrown when both android and androidV2 are set"); + } catch (IllegalArgumentException expected) { + assertEquals("android and androidV2 are mutually exclusive", expected.getMessage()); + } + } + + @Test + public void testMutualExclusivityRemoteNotificationAndBackgroundSync() { + AndroidRemoteNotification remoteNotification = AndroidRemoteNotification.builder() + .setNotification(AndroidNotificationV2.builder().build()) + .build(); + AndroidBackgroundSyncMessage backgroundSync = AndroidBackgroundSyncMessage.builder().build(); + try { + AndroidConfigV2.builder() + .setRemoteNotification(remoteNotification) + .setBackgroundSync(backgroundSync) + .build(); + fail("No error thrown when both remoteNotification and backgroundSync are set"); + } catch (IllegalArgumentException expected) { + assertEquals("Exactly one of remoteNotification or backgroundSync must be specified", + expected.getMessage()); + } + } + + @Test + public void testTtlMustNotBeNegative() { + try { + AndroidConfigV2.builder() + .setTtl(Duration.ofSeconds(-1)) + .setBackgroundSync(AndroidBackgroundSyncMessage.builder().build()) + .build(); + fail("No error thrown for negative TTL"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + @Test + public void testAndroidNotificationV2WithNegativeCount() { + try { + AndroidNotificationV2.builder().setNotificationCount(-1).build(); + fail("No error thrown for negative notification count"); + } catch (IllegalArgumentException expected) { + assertEquals("notificationCount if specified must be zero or positive valued", + expected.getMessage()); + } + } + + @Test + public void testInvalidAndroidNotificationV2() { + List notificationBuilders = ImmutableList.of( + AndroidNotificationV2.builder().setColor(""), + AndroidNotificationV2.builder().setColor("foo"), + AndroidNotificationV2.builder().setColor("123"), + AndroidNotificationV2.builder().setColor("#AABBCK"), + AndroidNotificationV2.builder().addBodyLocalizationArg("foo"), + AndroidNotificationV2.builder().addTitleLocalizationArg("foo") + ); + for (int i = 0; i < notificationBuilders.size(); i++) { + try { + notificationBuilders.get(i).build(); + fail("No error thrown for invalid notification V2: " + i); + } catch (IllegalArgumentException expected) { + // expected + } + } + } + + @Test + public void testAndroidNotificationV2WithNegativeVibrateTimings() { + try { + AndroidNotificationV2.builder().setVibrateTimingsInMillis(new long[]{1000L, -100L}); + fail("No error thrown for negative vibrate timing"); + } catch (IllegalArgumentException expected) { + assertEquals("elements in vibrateTimingsInMillis must not be negative", + expected.getMessage()); + } + } + private static void assertJsonEquals( Map expected, Object actual) throws IOException { assertEquals(expected, toMap(actual)); From f4a620340e6a42b6ab451c3bcdb07fef4c454df7 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 14 Jul 2026 15:53:10 -0400 Subject: [PATCH 2/3] fix(fcm): Address gemini review --- .../messaging/AndroidNotificationV2.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java b/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java index cbce3d204..3356e13f1 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java +++ b/src/main/java/com/google/firebase/messaging/AndroidNotificationV2.java @@ -17,19 +17,22 @@ package com.google.firebase.messaging; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.client.util.Key; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.NonNull; -import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.TimeZone; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * Represents the Android-specific notification options that can be included in a @@ -125,12 +128,18 @@ public final class AndroidNotificationV2 { .put(NotificationPriority.MAX, "PRIORITY_MAX") .build(); + private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private static final DateTimeFormatter EVENT_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'") + .withZone(ZoneOffset.UTC); + private AndroidNotificationV2(Builder builder) { this.title = builder.title; this.body = builder.body; this.icon = builder.icon; if (builder.color != null) { - checkArgument(builder.color.matches("^#[0-9a-fA-F]{6}$"), + checkArgument(COLOR_PATTERN.matcher(builder.color).matches(), "color must be in the form #RRGGBB"); } this.color = builder.color; @@ -173,7 +182,7 @@ private AndroidNotificationV2(Builder builder) { this.defaultLightSettings = builder.defaultLightSettings; this.visibility = (builder.visibility != null && builder.visibility != Visibility.UNSPECIFIED) - ? builder.visibility.name().toLowerCase() : null; + ? builder.visibility.name().toLowerCase(Locale.ENGLISH) : null; if (builder.notificationCount != null) { checkArgument(builder.notificationCount >= 0, "notificationCount if specified must be zero or positive valued"); @@ -354,6 +363,7 @@ public Builder addBodyLocalizationArg(String arg) { * @return This builder. */ public Builder addAllBodyLocalizationArgs(@NonNull List args) { + checkNotNull(args, "args must not be null"); this.bodyLocArgs.addAll(args); return this; } @@ -390,6 +400,7 @@ public Builder addTitleLocalizationArg(String arg) { * @return This builder. */ public Builder addAllTitleLocalizationArgs(@NonNull List args) { + checkNotNull(args, "args must not be null"); this.titleLocArgs.addAll(args); return this; } @@ -457,9 +468,7 @@ public Builder setSticky(boolean sticky) { * @return This builder. */ public Builder setEventTimeInMillis(long eventTimeInMillis) { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'"); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - this.eventTime = dateFormat.format(new Date(eventTimeInMillis)); + this.eventTime = EVENT_TIME_FORMATTER.format(Instant.ofEpochMilli(eventTimeInMillis)); return this; } @@ -503,6 +512,7 @@ public Builder setNotificationPriority(NotificationPriority priority) { * @return This builder. */ public Builder setVibrateTimingsInMillis(long[] vibrateTimingsInMillis) { + checkNotNull(vibrateTimingsInMillis, "vibrateTimingsInMillis must not be null"); List list = new ArrayList<>(); for (long value : vibrateTimingsInMillis) { checkArgument(value >= 0, "elements in vibrateTimingsInMillis must not be negative"); From 281974a06f1405ddee9aac24864f1074709f71ea Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 14 Jul 2026 18:34:47 -0400 Subject: [PATCH 3/3] fix(fcm): Add AndroidConfigV2 support to MulticastMessage --- .../firebase/messaging/MulticastMessage.java | 28 ++++++++++- .../messaging/MulticastMessageTest.java | 47 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/google/firebase/messaging/MulticastMessage.java b/src/main/java/com/google/firebase/messaging/MulticastMessage.java index a880a88f2..a62d9f976 100644 --- a/src/main/java/com/google/firebase/messaging/MulticastMessage.java +++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java @@ -18,9 +18,10 @@ import static com.google.common.base.Preconditions.checkArgument; -import com.google.api.client.util.Strings; +import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.Booleans; import com.google.firebase.internal.NonNull; import java.util.Collection; @@ -52,7 +53,9 @@ public class MulticastMessage { private final List fids; private final Map data; private final Notification notification; + @Deprecated private final AndroidConfig androidConfig; + private final AndroidConfigV2 androidConfigV2; private final WebpushConfig webpushConfig; private final ApnsConfig apnsConfig; private final FcmOptions fcmOptions; @@ -71,9 +74,16 @@ private MulticastMessage(Builder builder) { for (String fid : this.fids) { checkArgument(!Strings.isNullOrEmpty(fid), "none of the fids can be null or empty"); } + int androidConfigs = Booleans.countTrue( + builder.androidConfig != null, + builder.androidConfigV2 != null + ); + checkArgument(androidConfigs <= 1, + "androidConfig and androidConfigV2 are mutually exclusive"); this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; this.androidConfig = builder.androidConfig; + this.androidConfigV2 = builder.androidConfigV2; this.webpushConfig = builder.webpushConfig; this.apnsConfig = builder.apnsConfig; this.fcmOptions = builder.fcmOptions; @@ -103,6 +113,7 @@ private Message.Builder getMetadataBuilder() { Message.Builder builder = Message.builder() .setNotification(this.notification) .setAndroidConfig(this.androidConfig) + .setAndroidConfigV2(this.androidConfigV2) .setApnsConfig(this.apnsConfig) .setWebpushConfig(this.webpushConfig) .setFcmOptions(this.fcmOptions); @@ -128,7 +139,9 @@ public static class Builder { private final ImmutableList.Builder fids = ImmutableList.builder(); private final Map data = new HashMap<>(); private Notification notification; + @Deprecated private AndroidConfig androidConfig; + private AndroidConfigV2 androidConfigV2; private WebpushConfig webpushConfig; private ApnsConfig apnsConfig; private FcmOptions fcmOptions; @@ -207,12 +220,25 @@ public Builder setNotification(Notification notification) { * * @param androidConfig An {@link AndroidConfig} instance. * @return This builder. + * @deprecated Use {@link #setAndroidConfigV2(AndroidConfigV2)} instead. */ + @Deprecated public Builder setAndroidConfig(AndroidConfig androidConfig) { this.androidConfig = androidConfig; return this; } + /** + * Sets the Android-specific information to be included in the message. + * + * @param androidConfigV2 An {@link AndroidConfigV2} instance. + * @return This builder. + */ + public Builder setAndroidConfigV2(AndroidConfigV2 androidConfigV2) { + this.androidConfigV2 = androidConfigV2; + return this; + } + /** * Sets the Webpush-specific information to be included in the message. * diff --git a/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java index 481ccccc9..ebe17b087 100644 --- a/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java +++ b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java @@ -30,6 +30,9 @@ public class MulticastMessageTest { private static final AndroidConfig ANDROID = AndroidConfig.builder() .setCollapseKey("collapseKey") .build(); + private static final AndroidConfigV2 ANDROID_V2 = AndroidConfigV2.builder() + .setCollapseKey("collapseKeyV2") + .build(); private static final ApnsConfig APNS = ApnsConfig.builder() .setAps(Aps.builder() .setBadge(42) @@ -72,6 +75,50 @@ public void testNoTokens() { MulticastMessage.builder().build(); } + @Test + public void testMulticastMessageWithAndroidConfigV2() { + MulticastMessage multicastMessage = MulticastMessage.builder() + .setAndroidConfigV2(ANDROID_V2) + .setApnsConfig(APNS) + .setWebpushConfig(WEBPUSH) + .setNotification(NOTIFICATION) + .setFcmOptions(FCM_OPTIONS) + .putData("key1", "value1") + .putAllData(ImmutableMap.of("key2", "value2")) + .addFid("fid1") + .addAllFids(ImmutableList.of("fid2", "fid3")) + .build(); + + List messages = multicastMessage.getMessageList(); + + assertEquals(3, messages.size()); + for (int i = 0; i < 3; i++) { + Message message = messages.get(i); + assertSame(ANDROID_V2, message.getAndroidConfigV2()); + assertSame(APNS, message.getApnsConfig()); + assertSame(WEBPUSH, message.getWebpushConfig()); + assertSame(NOTIFICATION, message.getNotification()); + assertSame(FCM_OPTIONS, message.getFcmOptions()); + assertEquals(ImmutableMap.of("key1", "value1", "key2", "value2"), message.getData()); + assertEquals("fid" + (i + 1), message.getFid()); + } + } + + @Test + public void testMultipleAndroidConfigs() { + try { + MulticastMessage.builder() + .setAndroidConfig(ANDROID) + .setAndroidConfigV2(ANDROID_V2) + .addToken("token1") + .build(); + fail("No error thrown for multiple android configs"); + } catch (IllegalArgumentException expected) { + assertEquals("androidConfig and androidConfigV2 are mutually exclusive", + expected.getMessage()); + } + } + @Test public void testTooManyTokens() { MulticastMessage.Builder builder = MulticastMessage.builder();