diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs index fd697ee4..60e6a1e0 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs @@ -40,6 +40,7 @@ public async Task Send() Body = "Body", ImageUrl = "https://example.com/image.png", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { Priority = Priority.Normal, @@ -49,6 +50,7 @@ public async Task Send() BandwidthConstrainedOk = true, RestrictedSatelliteOk = true, }, +#pragma warning restore CS0618 }; var id = await FirebaseMessaging.DefaultInstance.SendAsync(message, dryRun: true); Assert.True(!string.IsNullOrEmpty(id)); @@ -67,6 +69,7 @@ public async Task SendEach() Body = "Body", ImageUrl = "https://example.com/image.png", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { Priority = Priority.Normal, @@ -76,6 +79,7 @@ public async Task SendEach() BandwidthConstrainedOk = false, RestrictedSatelliteOk = false, }, +#pragma warning restore CS0618 }; var message2 = new Message() { @@ -85,6 +89,7 @@ public async Task SendEach() Title = "Title", Body = "Body", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { Priority = Priority.Normal, @@ -94,6 +99,7 @@ public async Task SendEach() BandwidthConstrainedOk = true, RestrictedSatelliteOk = true, }, +#pragma warning restore CS0618 }; var response = await FirebaseMessaging.DefaultInstance.SendEachAsync(new[] { message1, message2 }, dryRun: true); Assert.NotNull(response); @@ -114,13 +120,13 @@ public async Task SendEachForMulticast() Title = "Title", Body = "Body", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { Priority = Priority.Normal, TimeToLive = TimeSpan.FromHours(1), RestrictedPackageName = "com.google.firebase.testing", }, -#pragma warning disable CS0618 Tokens = new[] { "token1", @@ -145,12 +151,14 @@ public async Task SendEachForMulticastFids() Title = "Title", Body = "Body", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { Priority = Priority.Normal, TimeToLive = TimeSpan.FromHours(1), RestrictedPackageName = "com.google.firebase.testing", }, +#pragma warning restore CS0618 Fids = new[] { "fid1", diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseMessagingSnippets.cs b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseMessagingSnippets.cs index 487b0266..1dc98ab9 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseMessagingSnippets.cs +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseMessagingSnippets.cs @@ -23,6 +23,7 @@ internal class FirebaseMessagingSnippets { internal static async Task SendToTokenAsync() { +#pragma warning disable CS0618 // [START send_to_token] // This registration token comes from the client FCM SDKs. var registrationToken = "YOUR_REGISTRATION_TOKEN"; @@ -35,9 +36,7 @@ internal static async Task SendToTokenAsync() { "score", "850" }, { "time", "2:45" }, }, -#pragma warning disable CS0618 Token = registrationToken, -#pragma warning restore CS0618 }; // Send a message to the device corresponding to the provided @@ -46,6 +45,7 @@ internal static async Task SendToTokenAsync() // Response is a message ID string. Console.WriteLine("Successfully sent message: " + response); // [END send_to_token] +#pragma warning restore CS0618 } internal static async Task SendToTopicAsync() @@ -124,6 +124,7 @@ internal static async Task SendDryRunAsync() internal static async Task SendEachAsync() { var registrationToken = "YOUR_REGISTRATION_TOKEN"; +#pragma warning disable CS0618 // [START send_all] // Create a list containing up to 500 messages. var messages = new List() @@ -135,9 +136,7 @@ internal static async Task SendEachAsync() Title = "Price drop", Body = "5% off all electronics", }, -#pragma warning disable CS0618 Token = registrationToken, -#pragma warning restore CS0618 }, new Message() { @@ -155,10 +154,12 @@ internal static async Task SendEachAsync() // for the contents of response. Console.WriteLine($"{response.SuccessCount} messages were sent successfully"); // [END send_all] +#pragma warning restore CS0618 } internal static async Task SendEachForMulticastAsync() { +#pragma warning disable CS0618 // [START send_multicast] // Create a list containing up to 500 registration tokens. // These registration tokens come from the client FCM SDKs. @@ -170,9 +171,7 @@ internal static async Task SendEachForMulticastAsync() }; var message = new MulticastMessage() { -#pragma warning disable CS0618 Tokens = registrationTokens, -#pragma warning restore CS0618 Data = new Dictionary() { { "score", "850" }, @@ -185,10 +184,12 @@ internal static async Task SendEachForMulticastAsync() // for the contents of response. Console.WriteLine($"{response.SuccessCount} messages were sent successfully"); // [END send_multicast] +#pragma warning restore CS0618 } internal static async Task SendEachForMulticastAndHandleErrorsAsync() { +#pragma warning disable CS0618 // [START send_multicast_error] // These registration tokens come from the client FCM SDKs. var registrationTokens = new List() @@ -199,9 +200,7 @@ internal static async Task SendEachForMulticastAndHandleErrorsAsync() }; var message = new MulticastMessage() { -#pragma warning disable CS0618 Tokens = registrationTokens, -#pragma warning restore CS0618 Data = new Dictionary() { { "score", "850" }, @@ -226,10 +225,12 @@ internal static async Task SendEachForMulticastAndHandleErrorsAsync() } // [END send_multicast_error] +#pragma warning restore CS0618 } internal static Message CreateAndroidMessage() { +#pragma warning disable CS0618 // [START android_message] var message = new Message { @@ -248,6 +249,33 @@ internal static Message CreateAndroidMessage() Topic = "industry-tech", }; // [END android_message] +#pragma warning restore CS0618 + return message; + } + + internal static Message CreateAndroidV2Message() + { + // [START android_v2_message] + var message = new Message + { + AndroidV2 = new AndroidConfigV2() + { + TimeToLive = TimeSpan.FromHours(1), + RemoteNotification = new AndroidRemoteNotification() + { + MutableContent = true, + Notification = new AndroidNotificationV2() + { + Title = "$GOOG up 1.43% on the day", + Body = "$GOOG gained 11.80 points to close at 835.67, up 1.43% on the day.", + Icon = "stock_ticker_update", + Color = "#f45342", + }, + }, + }, + Topic = "industry-tech", + }; + // [END android_v2_message] return message; } @@ -301,6 +329,7 @@ internal static Message CreateWebpushMessage() internal static Message CreateMultiPlatformsMessage() { +#pragma warning disable CS0618 // [START multi_platforms_message] var message = new Message { @@ -328,6 +357,7 @@ internal static Message CreateMultiPlatformsMessage() Topic = "industry-tech", }; // [END multi_platforms_message] +#pragma warning restore CS0618 return message; } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs index d03a81dd..68d07188 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs @@ -100,15 +100,15 @@ public async Task SendMessageCancel() var canceller = new CancellationTokenSource(); canceller.Cancel(); - #if NET6_0_OR_GREATER +#if NET6_0_OR_GREATER await Assert.ThrowsAsync( async () => await FirebaseMessaging.DefaultInstance.SendAsync( new Message() { Topic = "test-topic" }, canceller.Token)); - #else +#else await Assert.ThrowsAsync( async () => await FirebaseMessaging.DefaultInstance.SendAsync( new Message() { Topic = "test-topic" }, canceller.Token)); - #endif +#endif } [Fact] @@ -122,15 +122,15 @@ public async Task SendMessageCancelWithClientFactory() var canceller = new CancellationTokenSource(); canceller.Cancel(); - #if NET6_0_OR_GREATER +#if NET6_0_OR_GREATER await Assert.ThrowsAsync( async () => await FirebaseMessaging.DefaultInstance.SendAsync( new Message() { Topic = "test-topic" }, canceller.Token)); - #else +#else await Assert.ThrowsAsync( async () => await FirebaseMessaging.DefaultInstance.SendAsync( new Message() { Topic = "test-topic" }, canceller.Token)); - #endif +#endif } [Fact] diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs index b496067d..223006d2 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs @@ -119,10 +119,12 @@ public void MessageDeserialization() Title = "title", Body = "body", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { RestrictedPackageName = "test-pkg-name", }, +#pragma warning restore CS0618 Apns = new ApnsConfig() { Aps = new Aps() @@ -145,8 +147,10 @@ public void MessageDeserialization() Assert.Equal(original.Data, copy.Data); Assert.Equal(original.Notification.Title, copy.Notification.Title); Assert.Equal(original.Notification.Body, copy.Notification.Body); +#pragma warning disable CS0618 Assert.Equal( original.Android.RestrictedPackageName, copy.Android.RestrictedPackageName); +#pragma warning restore CS0618 Assert.Equal(original.Apns.Aps.AlertString, copy.Apns.Aps.AlertString); Assert.Equal(original.Webpush.Data, copy.Webpush.Data); Assert.Equal(original.FcmOptions.AnalyticsLabel, copy.FcmOptions.AnalyticsLabel); @@ -164,10 +168,12 @@ public void MessageDeserializationWithFid() Title = "title", Body = "body", }, +#pragma warning disable CS0618 Android = new AndroidConfig() { RestrictedPackageName = "test-pkg-name", }, +#pragma warning restore CS0618 Apns = new ApnsConfig() { Aps = new Aps() @@ -190,8 +196,10 @@ public void MessageDeserializationWithFid() Assert.Equal(original.Data, copy.Data); Assert.Equal(original.Notification.Title, copy.Notification.Title); Assert.Equal(original.Notification.Body, copy.Notification.Body); +#pragma warning disable CS0618 Assert.Equal( original.Android.RestrictedPackageName, copy.Android.RestrictedPackageName); +#pragma warning restore CS0618 Assert.Equal(original.Apns.Aps.AlertString, copy.Apns.Aps.AlertString); Assert.Equal(original.Webpush.Data, copy.Webpush.Data); Assert.Equal(original.FcmOptions.AnalyticsLabel, copy.FcmOptions.AnalyticsLabel); @@ -205,7 +213,9 @@ public void MessageCopy() Topic = "test-topic", Data = new Dictionary(), Notification = new Notification(), +#pragma warning disable CS0618 Android = new AndroidConfig(), +#pragma warning restore CS0618 Apns = new ApnsConfig(), Webpush = new WebpushConfig(), }; @@ -213,7 +223,9 @@ public void MessageCopy() Assert.NotSame(original, copy); Assert.NotSame(original.Data, copy.Data); Assert.NotSame(original.Notification, copy.Notification); +#pragma warning disable CS0618 Assert.NotSame(original.Android, copy.Android); +#pragma warning restore CS0618 Assert.NotSame(original.Apns, copy.Apns); Assert.NotSame(original.Webpush, copy.Webpush); } @@ -226,7 +238,9 @@ public void MessageWithFidOnly() Fid = "test-fid", Data = new Dictionary(), Notification = new Notification(), +#pragma warning disable CS0618 Android = new AndroidConfig(), +#pragma warning restore CS0618 Apns = new ApnsConfig(), Webpush = new WebpushConfig(), }; @@ -356,6 +370,7 @@ public void AndroidConfig() var message = new Message() { Topic = "test-topic", +#pragma warning disable CS0618 Android = new AndroidConfig() { CollapseKey = "collapse-key", @@ -409,6 +424,7 @@ public void AndroidConfig() AnalyticsLabel = "label", }, }, +#pragma warning restore CS0618 }; var expected = new JObject() { @@ -492,7 +508,9 @@ public void AndroidConfigMinimal() var message = new Message() { Topic = "test-topic", +#pragma warning disable CS0618 Android = new AndroidConfig(), +#pragma warning restore CS0618 }; var expected = new JObject() { @@ -508,10 +526,12 @@ public void AndroidConfigFullSecondsTTL() var message = new Message() { Topic = "test-topic", +#pragma warning disable CS0618 Android = new AndroidConfig() { TimeToLive = TimeSpan.FromHours(1), }, +#pragma warning restore CS0618 }; var expected = new JObject() { @@ -526,9 +546,182 @@ public void AndroidConfigFullSecondsTTL() this.AssertJsonEquals(expected, message); } + [Fact] + public void AndroidConfigV2Remote() + { + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + CollapseKey = "collapse-key", + TimeToLive = TimeSpan.FromMilliseconds(10), + RestrictedPackageName = "test-pkg-name", + Data = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + DirectBootOk = true, + BandwidthConstrainedOk = true, + RestrictedSatelliteOk = true, + RemoteNotification = new AndroidRemoteNotification() + { + MutableContent = true, + UseAsV1DataMessage = true, + Notification = new AndroidNotificationV2() + { + Title = "title", + Body = "body", + Icon = "icon", + Color = "#112233", + Sound = "sound", + Tag = "tag", + ImageUrl = "https://example.com/image.png", + ClickAction = "click-action", + TitleLocKey = "title-loc-key", + TitleLocArgs = new List() { "arg1", "arg2" }, + BodyLocKey = "body-loc-key", + BodyLocArgs = new List() { "arg3", "arg4" }, + ChannelId = "channel-id", + Ticker = "ticker", + Sticky = false, + EventTimestamp = DateTime.Parse("2020-06-27T16:29:06.032691000-04:00"), + LocalOnly = true, + Priority = NotificationPriority.HIGH, + VibrateTimingsMillis = new long[] { 1000L, 1001L }, + DefaultVibrateTimings = false, + DefaultSound = true, + LightSettings = new LightSettings() + { + Color = "#aabbccdd", + LightOnDurationMillis = 1002L, + LightOffDurationMillis = 1003L, + }, + DefaultLightSettings = false, + Visibility = NotificationVisibility.PUBLIC, + NotificationCount = 10, + Id = 100, + }, + }, + FcmOptions = new AndroidFcmOptions() + { + AnalyticsLabel = "label", + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "androidV2", new JObject() + { + { "collapse_key", "collapse-key" }, + { "ttl", "0.010000000s" }, + { "restricted_package_name", "test-pkg-name" }, + { "data", new JObject() { { "k1", "v1" }, { "k2", "v2" } } }, + { "direct_boot_ok", true }, + { "bandwidth_constrained_ok", true }, + { "restricted_satellite_ok", true }, + { + "remote_notification", new JObject() + { + { "mutable_content", true }, + { "use_as_v1_data_message", true }, + { + "notification", new JObject() + { + { "title", "title" }, + { "body", "body" }, + { "icon", "icon" }, + { "color", "#112233" }, + { "sound", "sound" }, + { "tag", "tag" }, + { "image", "https://example.com/image.png" }, + { "click_action", "click-action" }, + { "title_loc_key", "title-loc-key" }, + { "title_loc_args", new JArray() { "arg1", "arg2" } }, + { "body_loc_key", "body-loc-key" }, + { "body_loc_args", new JArray() { "arg3", "arg4" } }, + { "channel_id", "channel-id" }, + { "ticker", "ticker" }, + { "sticky", false }, + { "local_only", true }, + { "default_vibrate_timings", false }, + { "default_sound", true }, + { + "light_settings", new JObject() + { + { "light_on_duration", "1.002000000s" }, + { "light_off_duration", "1.003000000s" }, + { + "color", new JObject() + { + { "red", 0.6666667 }, + #if NET6_0_OR_GREATER + { "green", 0.73333335 }, + #else + { "green", 0.733333349 }, + #endif + { "blue", 0.8 }, + { "alpha", 0.8666667 }, + } + }, + } + }, + { "default_light_settings", false }, + { "notification_count", 10 }, + { "notification_priority", "PRIORITY_HIGH" }, + { "visibility", "PUBLIC" }, + { "vibrate_timings", new JArray() { "1s", "1.001000000s" } }, + { "event_time", "2020-06-27T20:29:06.032691000Z" }, + { "id", 100 }, + } + }, + } + }, + { + "fcm_options", new JObject() + { + { "analytics_label", "label" }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void AndroidConfigV2BackgroundSync() + { + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + CollapseKey = "collapse-key", + BackgroundSync = new AndroidBackgroundSyncMessage(), + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "androidV2", new JObject() + { + { "collapse_key", "collapse-key" }, + { "background_sync", new JObject() }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + [Fact] public void AndroidConfigDeserialization() { +#pragma warning disable CS0618 var original = new AndroidConfig() { CollapseKey = "collapse-key", @@ -548,8 +741,11 @@ public void AndroidConfigDeserialization() EventTimestamp = DateTime.Parse("2020-06-27T20:29:06.032691000Z"), }, }; +#pragma warning restore CS0618 var json = NewtonsoftJsonSerializer.Instance.Serialize(original); +#pragma warning disable CS0618 var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); +#pragma warning restore CS0618 Assert.Equal(original.CollapseKey, copy.CollapseKey); Assert.Equal(original.RestrictedPackageName, copy.RestrictedPackageName); Assert.Equal(original.Priority, copy.Priority); @@ -561,14 +757,80 @@ public void AndroidConfigDeserialization() Assert.Equal(original.Notification.Title, copy.Notification.Title); } + [Fact] + public void AndroidConfigV2Deserialization() + { + var original = new AndroidConfigV2() + { + CollapseKey = "collapse-key", + TimeToLive = TimeSpan.FromMilliseconds(10), + RestrictedPackageName = "test-pkg-name", + Data = new Dictionary() { { "k1", "v1" } }, + RemoteNotification = new AndroidRemoteNotification() + { + MutableContent = true, + UseAsV1DataMessage = true, + Notification = new AndroidNotificationV2() + { + Title = "title", + Body = "body", + Id = 100, + }, + }, + FcmOptions = new AndroidFcmOptions() + { + AnalyticsLabel = "label", + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.CollapseKey, copy.CollapseKey); + Assert.Equal(original.TimeToLive, copy.TimeToLive); + Assert.Equal(original.RestrictedPackageName, copy.RestrictedPackageName); + Assert.Equal(original.Data, copy.Data); + Assert.Equal(original.RemoteNotification.MutableContent, copy.RemoteNotification.MutableContent); + Assert.Equal(original.RemoteNotification.UseAsV1DataMessage, copy.RemoteNotification.UseAsV1DataMessage); + Assert.Equal(original.RemoteNotification.Notification.Title, copy.RemoteNotification.Notification.Title); + Assert.Equal(original.RemoteNotification.Notification.Body, copy.RemoteNotification.Notification.Body); + Assert.Equal(original.RemoteNotification.Notification.Id, copy.RemoteNotification.Notification.Id); + Assert.Equal(original.FcmOptions.AnalyticsLabel, copy.FcmOptions.AnalyticsLabel); + } + + [Fact] + public void AndroidConfigV2ZeroFractionalTtlDeserialization() + { + var json = @"{ ""ttl"": ""1.000000000s"" }"; + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(TimeSpan.FromSeconds(1), copy.TimeToLive); + } + + [Fact] + public void AndroidConfigV2FractionalTtlDeserialization() + { + var json = @"{ ""ttl"": ""1.5s"" }"; + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(TimeSpan.FromSeconds(1.5), copy.TimeToLive); + } + + [Fact] + public void AndroidNotificationV2EventTimeDeserialization() + { + var json = @"{ ""event_time"": ""2026-07-28T22:00:00Z"" }"; + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(DateTimeKind.Utc, copy.EventTimestamp.Value.Kind); + Assert.Equal(new DateTime(2026, 7, 28, 22, 0, 0, DateTimeKind.Utc), copy.EventTimestamp); + } + [Fact] public void AndroidConfigCopy() { +#pragma warning disable CS0618 var original = new AndroidConfig() { Data = new Dictionary(), Notification = new AndroidNotification(), }; +#pragma warning restore CS0618 var copy = original.CopyAndValidate(); Assert.NotSame(original, copy); Assert.NotSame(original.Data, copy.Data); @@ -578,6 +840,7 @@ public void AndroidConfigCopy() [Fact] public void AndroidNotificationDeserialization() { +#pragma warning disable CS0618 var original = new AndroidNotification() { Title = "title", @@ -612,8 +875,11 @@ public void AndroidNotificationDeserialization() NotificationCount = 10, Proxy = NotificationProxy.IfPriorityLowered, }; +#pragma warning restore CS0618 var json = NewtonsoftJsonSerializer.Instance.Serialize(original); +#pragma warning disable CS0618 var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); +#pragma warning restore CS0618 Assert.Equal(original.Title, copy.Title); Assert.Equal(original.Body, copy.Body); Assert.Equal(original.Icon, copy.Icon); @@ -647,6 +913,7 @@ public void AndroidNotificationDeserialization() [Fact] public void AndroidNotificationCopy() { +#pragma warning disable CS0618 var original = new AndroidNotification() { TitleLocKey = "title-loc-key", @@ -654,15 +921,41 @@ public void AndroidNotificationCopy() BodyLocKey = "body-loc-key", BodyLocArgs = new List() { "arg3", "arg4" }, }; +#pragma warning restore CS0618 var copy = original.CopyAndValidate(); Assert.NotSame(original, copy); Assert.NotSame(original.TitleLocArgs, copy.TitleLocArgs); Assert.NotSame(original.BodyLocArgs, copy.BodyLocArgs); } + [Fact] + public void AndroidConfigV2Copy() + { + var original = new AndroidConfigV2() + { + CollapseKey = "collapse-key", + Data = new Dictionary() { { "k1", "v1" } }, + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() + { + Title = "title", + }, + }, + FcmOptions = new AndroidFcmOptions(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Data, copy.Data); + Assert.NotSame(original.RemoteNotification, copy.RemoteNotification); + Assert.NotSame(original.RemoteNotification.Notification, copy.RemoteNotification.Notification); + Assert.NotSame(original.FcmOptions, copy.FcmOptions); + } + [Fact] public void AndroidConfigInvalidTTL() { +#pragma warning disable CS0618 var message = new Message() { Topic = "test-topic", @@ -672,11 +965,72 @@ public void AndroidConfigInvalidTTL() }, }; Assert.Throws(() => message.CopyAndValidate()); +#pragma warning restore CS0618 + } + + [Fact] + public void AndroidConfigV2InvalidTTL() + { + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + TimeToLive = TimeSpan.FromSeconds(-1), + BackgroundSync = new AndroidBackgroundSyncMessage(), + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidConfigV2MutuallyExclusivePayload() + { + // Both RemoteNotification and BackgroundSync specified + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() { Title = "title" }, + }, + BackgroundSync = new AndroidBackgroundSyncMessage(), + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + + // Neither specified + message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2(), + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidConfigV2MutuallyExclusiveConfig() + { +#pragma warning disable CS0618 + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig(), + AndroidV2 = new AndroidConfigV2() + { + BackgroundSync = new AndroidBackgroundSyncMessage(), + }, + }; + Assert.Throws(() => message.CopyAndValidate()); +#pragma warning restore CS0618 } [Fact] public void AndroidNotificationInvalidColor() { +#pragma warning disable CS0618 var message = new Message() { Topic = "test-topic", @@ -689,11 +1043,13 @@ public void AndroidNotificationInvalidColor() }, }; Assert.Throws(() => message.CopyAndValidate()); +#pragma warning restore CS0618 } [Fact] public void AndroidNotificationInvalidImageUrls() { +#pragma warning disable CS0618 var imageUrls = new List() { string.Empty, "image.png", "invalid-image", "foo bar", @@ -713,11 +1069,13 @@ public void AndroidNotificationInvalidImageUrls() }; Assert.Throws(() => message.CopyAndValidate()); } +#pragma warning restore CS0618 } [Fact] public void AndroidNotificationInvalidTitleLocArgs() { +#pragma warning disable CS0618 var message = new Message() { Topic = "test-topic", @@ -730,11 +1088,13 @@ public void AndroidNotificationInvalidTitleLocArgs() }, }; Assert.Throws(() => message.CopyAndValidate()); +#pragma warning restore CS0618 } [Fact] public void AndroidNotificationInvalidBodyLocArgs() { +#pragma warning disable CS0618 var message = new Message() { Topic = "test-topic", @@ -747,6 +1107,119 @@ public void AndroidNotificationInvalidBodyLocArgs() }, }; Assert.Throws(() => message.CopyAndValidate()); +#pragma warning restore CS0618 + } + + [Fact] + public void AndroidNotificationV2NullNotification() + { + // Null notification in remote notification + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = null, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationV2InvalidColor() + { + // Invalid color format + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() + { + Title = "title", + Color = "red", + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationV2InvalidTitleLocArgs() + { + // TitleLocArgs specified without TitleLocKey + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() + { + Title = "title", + TitleLocArgs = new List() { "arg" }, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationV2InvalidBodyLocArgs() + { + // BodyLocArgs specified without BodyLocKey + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() + { + Title = "title", + BodyLocArgs = new List() { "arg" }, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationV2InvalidImageUrls() + { + var imageUrls = new List() + { + string.Empty, "image.png", "invalid-image", "foo bar", + }; + foreach (var imageUrl in imageUrls) + { + var message = new Message() + { + Topic = "test-topic", + AndroidV2 = new AndroidConfigV2() + { + RemoteNotification = new AndroidRemoteNotification() + { + Notification = new AndroidNotificationV2() + { + Title = "title", + ImageUrl = imageUrl, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } } [Fact] diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MulticastMessageTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MulticastMessageTest.cs index d94b997e..a58ad3f9 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MulticastMessageTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MulticastMessageTest.cs @@ -116,5 +116,26 @@ public void GetMessageListTooManyCombinedTargets() Assert.Throws(() => message.GetMessageList()); } #pragma warning restore CS0618 + + [Fact] + public void GetMessageListWithAndroidV2() + { + var androidV2 = new AndroidConfigV2() + { + BackgroundSync = new AndroidBackgroundSyncMessage(), + }; + var message = new MulticastMessage + { + Fids = new[] { "test-fid1" }, + AndroidV2 = androidV2, + }; + + var messages = message.GetMessageList(); + + Assert.Single(messages); + Assert.Equal("test-fid1", messages[0].Fid); + Assert.NotNull(messages[0].AndroidV2.BackgroundSync); + Assert.NotSame(androidV2.BackgroundSync, messages[0].AndroidV2.BackgroundSync); + } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidBackgroundSyncMessage.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidBackgroundSyncMessage.cs new file mode 100644 index 00000000..fe50df9b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidBackgroundSyncMessage.cs @@ -0,0 +1,31 @@ +// Copyright 2026, Google Inc. All rights reserved. +// +// 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. + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents a background sync message for Android V2 config. + /// + public sealed class AndroidBackgroundSyncMessage + { + /// + /// Copies this background sync message, and validates the content of it to ensure that it can + /// be serialized into the JSON format expected by the FCM service. + /// + internal AndroidBackgroundSyncMessage CopyAndValidate() + { + return new AndroidBackgroundSyncMessage(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs index 60b745c6..336ed9cd 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs @@ -20,7 +20,9 @@ namespace FirebaseAdmin.Messaging { /// /// Represents the Android-specific options that can be included in a . + /// Deprecated. Use instead. /// + [Obsolete("Deprecated. Use AndroidConfigV2 instead.")] public sealed class AndroidConfig { /// diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfigV2.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfigV2.cs new file mode 100644 index 00000000..9ba7aa20 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfigV2.cs @@ -0,0 +1,181 @@ +// Copyright 2026, Google Inc. All rights reserved. +// +// 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. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Android-specific options that can be included in a for V2 configuration. + /// + public sealed class AndroidConfigV2 + { + /// + /// Gets or sets a 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 4 different collapse keys may be active at any + /// given time. + /// + [JsonProperty("collapse_key")] + public string CollapseKey { get; set; } + + /// + /// Gets or sets the time-to-live duration of the message. + /// + [JsonIgnore] + public TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the package name of the application where the registration tokens must + /// match in order to receive the message. + /// + [JsonProperty("restricted_package_name")] + public string RestrictedPackageName { get; set; } + + /// + /// Gets or sets a collection of key-value pairs that will be added to the message as data + /// fields. Keys and the values must not be null. When set, overrides any data fields set + /// on the top-level + /// . + /// + [JsonProperty("data")] + public IReadOnlyDictionary Data { get; set; } + + /// + /// Gets or sets a boolean indicating whether messages will be allowed to be delivered to + /// the app while the device is in direct boot mode. + /// + [JsonProperty("direct_boot_ok")] + public bool? DirectBootOk { get; set; } + + /// + /// Gets or sets a boolean indicating whether messages will be allowed to be delivered to + /// the app while the device is on a bandwidth constrained network. + /// + [JsonProperty("bandwidth_constrained_ok")] + public bool? BandwidthConstrainedOk { get; set; } + + /// + /// Gets or sets a boolean indicating whether messages will be allowed to be delivered to + /// the app while the device is on a restricted satellite network. + /// + [JsonProperty("restricted_satellite_ok")] + public bool? RestrictedSatelliteOk { get; set; } + + /// + /// Gets or sets the RemoteNotification payload configuration. + /// + [JsonProperty("remote_notification")] + public AndroidRemoteNotification RemoteNotification { get; set; } + + /// + /// Gets or sets the BackgroundSync payload configuration. + /// + [JsonProperty("background_sync")] + public AndroidBackgroundSyncMessage BackgroundSync { get; set; } + + /// + /// Gets or sets the FCM options to be included in the message. + /// + [JsonProperty("fcm_options")] + public AndroidFcmOptions FcmOptions { get; set; } + + /// + /// Gets or sets the string representation of as accepted by the + /// FCM backend service. The string ends in the suffix "s" (indicating seconds) and is + /// preceded by the number of seconds, with nanoseconds expressed as fractional seconds. + /// + [JsonProperty("ttl")] + private string TtlString + { + get + { + if (this.TimeToLive == null) + { + return null; + } + + var ticks = this.TimeToLive.Value.Ticks; + var seconds = ticks / TimeSpan.TicksPerSecond; + var subsecondNanos = (ticks % TimeSpan.TicksPerSecond) * 100; + if (subsecondNanos > 0) + { + return string.Format(CultureInfo.InvariantCulture, "{0}.{1:D9}s", seconds, subsecondNanos); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}s", seconds); + } + + set + { + if (value == null) + { + this.TimeToLive = null; + return; + } + + var segments = value.TrimEnd('s').Split('.'); + var seconds = long.Parse(segments[0], CultureInfo.InvariantCulture); + var ttl = TimeSpan.FromSeconds(seconds); + if (segments.Length == 2) + { + var fractionStr = segments[1].PadRight(9, '0').Substring(0, 9); + var nanoseconds = long.Parse(fractionStr, CultureInfo.InvariantCulture); + ttl = ttl.Add(TimeSpan.FromTicks(nanoseconds / 100)); + } + + this.TimeToLive = ttl; + } + } + + /// + /// Copies this Android V2 config, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal AndroidConfigV2 CopyAndValidate() + { + var copy = new AndroidConfigV2() + { + CollapseKey = this.CollapseKey, + TimeToLive = this.TimeToLive, + RestrictedPackageName = this.RestrictedPackageName, + Data = this.Data?.Copy(), + DirectBootOk = this.DirectBootOk, + BandwidthConstrainedOk = this.BandwidthConstrainedOk, + RestrictedSatelliteOk = this.RestrictedSatelliteOk, + RemoteNotification = this.RemoteNotification?.CopyAndValidate(), + BackgroundSync = this.BackgroundSync?.CopyAndValidate(), + FcmOptions = this.FcmOptions?.CopyAndValidate(), + }; + + var totalSeconds = copy.TimeToLive?.TotalSeconds ?? 0; + if (totalSeconds < 0) + { + throw new ArgumentException("TTL must not be negative."); + } + + var hasRemote = copy.RemoteNotification != null; + var hasSync = copy.BackgroundSync != null; + if (hasRemote == hasSync) + { + throw new ArgumentException("Exactly one of RemoteNotification or BackgroundSync must be specified on AndroidConfigV2."); + } + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs index 19084a05..9200c046 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs @@ -25,7 +25,9 @@ namespace FirebaseAdmin.Messaging /// /// Represents the Android-specific notification options that can be included in a /// . + /// Deprecated. Use instead. /// + [Obsolete("Deprecated. Use AndroidNotificationV2 instead.")] public sealed class AndroidNotification { /// @@ -36,7 +38,7 @@ public sealed class AndroidNotification public string Title { get; set; } /// - /// Gets or sets the title of the Android notification. When provided, overrides the title + /// Gets or sets the body text of the Android notification. When provided, overrides the body /// set via . /// [JsonProperty("body")] @@ -176,10 +178,10 @@ public sealed class AndroidNotification /// /// Gets or sets a value indicating whether or not to use the default vibration timings. If set to true, use the Android - /// Sets the whether to use the default vibration timings. If set to true, use the Android - /// in config.xml. - /// If is set to true and is also set, - /// the default value is used instead of the user-specified . + /// framework's default vibrate pattern for the notification. Default values are specified in + /// config.xml. + /// If is set to true and is also set, + /// the default value is used instead of the user-specified . /// [JsonProperty("default_vibrate_timings")] public bool DefaultVibrateTimings { get; set; } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotificationV2.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotificationV2.cs new file mode 100644 index 00000000..c48663f6 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotificationV2.cs @@ -0,0 +1,482 @@ +// Copyright 2026, Google Inc. All rights reserved. +// +// 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. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using FirebaseAdmin.Messaging.Util; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Android-specific notification options that can be included in an + /// . + /// + public sealed class AndroidNotificationV2 + { + /// + /// Gets or sets the title of the Android V2 notification. When provided, overrides the title + /// set via . + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the body text of the Android V2 notification. When provided, overrides the body + /// set via . + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Gets or sets the icon of the Android V2 notification. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + + /// + /// Gets or sets the notification icon color. Must be of the form #RRGGBB. + /// + [JsonProperty("color")] + public string Color { get; set; } + + /// + /// Gets or sets the sound to be played when the device receives the notification. + /// + [JsonProperty("sound")] + public string Sound { get; set; } + + /// + /// Gets or 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. + /// + [JsonProperty("tag")] + public string Tag { get; set; } + + /// + /// Gets or sets the URL of the image to be displayed in the notification. + /// + [JsonProperty("image")] + public string ImageUrl { get; set; } + + /// + /// Gets or 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. + /// + [JsonProperty("click_action")] + public string ClickAction { get; set; } + + /// + /// Gets or sets the key of the title string in the app's string resources to use to + /// localize the title text. + /// + [JsonProperty("title_loc_key")] + public string TitleLocKey { get; set; } + + /// + /// Gets or sets the collection of resource key strings that will be used in place of the + /// format specifiers in . + /// + [JsonProperty("title_loc_args")] + public IEnumerable TitleLocArgs { get; set; } + + /// + /// Gets or sets the key of the body string in the app's string resources to use to + /// localize the body text. + /// + [JsonProperty("body_loc_key")] + public string BodyLocKey { get; set; } + + /// + /// Gets or sets the collection of resource key strings that will be used in place of the + /// format specifiers in . + /// + [JsonProperty("body_loc_args")] + public IEnumerable BodyLocArgs { get; set; } + + /// + /// Gets or 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. + /// + [JsonProperty("channel_id")] + public string ChannelId { get; set; } + + /// + /// Gets or sets the "ticker" text which is sent to accessibility services. Prior to API level 21 + /// (Lollipop), gets or sets the text that is displayed in the status bar when the notification + /// first arrives. + /// + [JsonProperty("ticker")] + public string Ticker { get; set; } + + /// + /// Gets or sets a value indicating whether the notification is automatically dismissed + /// or persists when the user clicks it in the panel. When set to false, + /// the notification is automatically dismissed. When set to true, the notification persists. + /// + [JsonProperty("sticky")] + public bool? Sticky { get; set; } + + /// + /// Gets or sets the time that the event in the notification occurred for notifications + /// that inform users about events with an absolute time reference. Notifications in the panel + /// are sorted by this time. + /// + [JsonIgnore] + public DateTime? EventTimestamp { get; set; } + + /// + /// Gets or sets a value indicating 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 Wear OS guides. + /// + [JsonProperty("local_only")] + public bool? LocalOnly { get; set; } + + /// + /// Gets or 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. + /// + [JsonIgnore] + public NotificationPriority? Priority { get; set; } + + /// + /// Gets or 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 is set and + /// is set to true, the default value is used instead of + /// the user-specified . A duration in seconds with up to nine fractional digits, + /// terminated by 's'.Example: "3.5s". + /// + [JsonIgnore] + public IReadOnlyList VibrateTimingsMillis { get; set; } + + /// + /// Gets or sets a value indicating whether or not 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 + /// config.xml. + /// If is set to true and is also set, + /// the default value is used instead of the user-specified . + /// + [JsonProperty("default_vibrate_timings")] + public bool? DefaultVibrateTimings { get; set; } + + /// + /// Gets or sets a value indicating whether or not 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. + /// + [JsonProperty("default_sound")] + public bool? DefaultSound { get; set; } + + /// + /// Gets or 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. + /// + [JsonProperty("light_settings")] + public LightSettings LightSettings { get; set; } + + /// + /// Gets or sets a value indicating whether or not 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 is set to true and is also set, + /// the user-specified is used instead of the default value. + /// + [JsonProperty("default_light_settings")] + public bool? DefaultLightSettings { get; set; } + + /// + /// Gets or sets the visibility of this notification. + /// + [JsonIgnore] + public NotificationVisibility? Visibility { get; set; } + + /// + /// Gets or 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. + /// + [JsonProperty("notification_count")] + public int? NotificationCount { get; set; } + + /// + /// Gets or sets the request-scoped identifier of this notification. If not specified, each + /// request creates a new notification. If specified and a notification with the same tag is + /// already active, the new notification replaces the old one in the notification drawer. + /// + [JsonProperty("id")] + public int? Id { get; set; } + + /// + /// Gets or sets the string representation of the property. + /// + [JsonProperty("notification_priority")] + private string PriorityString + { + get + { + switch (this.Priority) + { + case NotificationPriority.MIN: + return "PRIORITY_MIN"; + case NotificationPriority.LOW: + return "PRIORITY_LOW"; + case NotificationPriority.DEFAULT: + return "PRIORITY_DEFAULT"; + case NotificationPriority.HIGH: + return "PRIORITY_HIGH"; + case NotificationPriority.MAX: + return "PRIORITY_MAX"; + default: + return null; + } + } + + set + { + if (value == null) + { + this.Priority = null; + return; + } + + switch (value) + { + case "PRIORITY_MIN": + this.Priority = NotificationPriority.MIN; + return; + case "PRIORITY_LOW": + this.Priority = NotificationPriority.LOW; + return; + case "PRIORITY_DEFAULT": + this.Priority = NotificationPriority.DEFAULT; + return; + case "PRIORITY_HIGH": + this.Priority = NotificationPriority.HIGH; + return; + case "PRIORITY_MAX": + this.Priority = NotificationPriority.MAX; + return; + default: + throw new ArgumentException( + $"Invalid priority value: {value}. Only 'PRIORITY_MIN', 'PRIORITY_LOW', 'PRIORITY_DEFAULT', " + + "'PRIORITY_HIGH', 'PRIORITY_MAX' are allowed."); + } + } + } + + /// + /// Gets or sets the string representation of the property. + /// + [JsonProperty("visibility")] + private string VisibilityString + { + get + { + switch (this.Visibility) + { + case NotificationVisibility.PUBLIC: + return "PUBLIC"; + case NotificationVisibility.PRIVATE: + return "PRIVATE"; + case NotificationVisibility.SECRET: + return "SECRET"; + default: + return null; + } + } + + set + { + if (value == null) + { + this.Visibility = null; + return; + } + + switch (value) + { + case "PUBLIC": + this.Visibility = NotificationVisibility.PUBLIC; + return; + case "PRIVATE": + this.Visibility = NotificationVisibility.PRIVATE; + return; + case "SECRET": + this.Visibility = NotificationVisibility.SECRET; + return; + default: + throw new ArgumentException( + $"Invalid visibility value: {value}. Only 'PUBLIC', 'PRIVATE', 'SECRET' are allowed."); + } + } + } + + /// + /// Gets or sets the string representation of the property. + /// + [JsonProperty("vibrate_timings")] + private List VibrateTimingsString + { + get + { + if (this.VibrateTimingsMillis == null) + { + return null; + } + + var timingsStringList = new List(); + foreach (var value in this.VibrateTimingsMillis) + { + timingsStringList.Add(TimeConverter.LongMillisToString(value)); + } + + return timingsStringList; + } + + set + { + if (value == null) + { + this.VibrateTimingsMillis = null; + return; + } + + if (value.Count == 0) + { + throw new ArgumentException("Invalid VibrateTimingsMillis. VibrateTimingsMillis should be a non-empty list of strings"); + } + + var timingsLongList = new List(); + foreach (var timingString in value) + { + timingsLongList.Add(TimeConverter.StringToLongMillis(timingString)); + } + + this.VibrateTimingsMillis = timingsLongList.ToArray(); + } + } + + /// + /// Gets or sets the string representation of the property. + /// + [JsonProperty("event_time")] + private string EventTimeString + { + get + { + if (this.EventTimestamp == null) + { + return null; + } + + return this.EventTimestamp.Value.ToUniversalTime().ToString( + "yyyy-MM-dd'T'HH:mm:ss.ffffff000'Z'", CultureInfo.InvariantCulture); + } + + set + { + if (value == null) + { + this.EventTimestamp = null; + return; + } + + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("Invalid event timestamp. Event timestamp should be a non-empty string"); + } + + this.EventTimestamp = DateTime.Parse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); + } + } + + /// + /// Copies this notification, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal AndroidNotificationV2 CopyAndValidate() + { + var copy = new AndroidNotificationV2() + { + Title = this.Title, + Body = this.Body, + Icon = this.Icon, + Color = this.Color, + Sound = this.Sound, + Tag = this.Tag, + ImageUrl = this.ImageUrl, + ClickAction = this.ClickAction, + TitleLocKey = this.TitleLocKey, + TitleLocArgs = this.TitleLocArgs?.ToList(), + BodyLocKey = this.BodyLocKey, + BodyLocArgs = this.BodyLocArgs?.ToList(), + ChannelId = this.ChannelId, + Ticker = this.Ticker, + Sticky = this.Sticky, + EventTimestamp = this.EventTimestamp, + LocalOnly = this.LocalOnly, + Priority = this.Priority, + VibrateTimingsMillis = this.VibrateTimingsMillis?.ToList(), + DefaultVibrateTimings = this.DefaultVibrateTimings, + DefaultSound = this.DefaultSound, + DefaultLightSettings = this.DefaultLightSettings, + Visibility = this.Visibility, + NotificationCount = this.NotificationCount, + Id = this.Id, + }; + + if (copy.Color != null && !Regex.Match(copy.Color, "^#[0-9a-fA-F]{6}$").Success) + { + throw new ArgumentException("Color must be in the form #RRGGBB."); + } + + if (copy.TitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.TitleLocKey)) + { + throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs."); + } + + if (copy.BodyLocArgs?.Any() == true && string.IsNullOrEmpty(copy.BodyLocKey)) + { + throw new ArgumentException("BodyLocKey is required when specifying BodyLocArgs."); + } + + if (copy.ImageUrl != null && !Uri.IsWellFormedUriString(copy.ImageUrl, UriKind.Absolute)) + { + throw new ArgumentException($"Malformed image URL string: {copy.ImageUrl}."); + } + + copy.LightSettings = this.LightSettings?.CopyAndValidate(); + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidRemoteNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidRemoteNotification.cs new file mode 100644 index 00000000..87b6d0f1 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidRemoteNotification.cs @@ -0,0 +1,60 @@ +// Copyright 2026, Google Inc. All rights reserved. +// +// 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. + +using System; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the remote notification configurations for Android V2 config. + /// + public sealed class AndroidRemoteNotification + { + /// + /// Gets or sets a value indicating whether to invoke the app's code to modify the notification before displaying on device. + /// + [JsonProperty("mutable_content")] + public bool? MutableContent { get; set; } + + /// + /// Gets or sets the Android V2 notification details. + /// + [JsonProperty("notification")] + public AndroidNotificationV2 Notification { get; set; } + + /// + /// Gets or sets a value indicating whether legacy clients should treat this message as a data message. + /// + [JsonProperty("use_as_v1_data_message")] + public bool? UseAsV1DataMessage { get; set; } + + internal AndroidRemoteNotification CopyAndValidate() + { + if (this.Notification == null) + { + throw new ArgumentException("Notification must be specified on AndroidRemoteNotification."); + } + + var copy = new AndroidRemoteNotification() + { + MutableContent = this.MutableContent, + UseAsV1DataMessage = this.UseAsV1DataMessage, + Notification = this.Notification.CopyAndValidate(), + }; + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs index 29d7b315..0f6be644 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs @@ -70,10 +70,18 @@ public sealed class Message /// /// Gets or sets the Android-specific information to be included in the message. + /// Deprecated. Use instead. /// + [Obsolete("Deprecated. Use AndroidV2 instead.")] [JsonProperty("android")] public AndroidConfig Android { get; set; } + /// + /// Gets or sets the Android V2-specific information to be included in the message. + /// + [JsonProperty("androidV2")] + public AndroidConfigV2 AndroidV2 { get; set; } + /// /// Gets or sets the Webpush-specific information to be included in the message. /// @@ -133,6 +141,11 @@ internal Message CopyAndValidate() Topic = this.Topic, Condition = this.Condition, Data = this.Data?.Copy(), + Notification = this.Notification?.CopyAndValidate(), + Android = this.Android?.CopyAndValidate(), + AndroidV2 = this.AndroidV2?.CopyAndValidate(), + Webpush = this.Webpush?.CopyAndValidate(), + Apns = this.Apns?.CopyAndValidate(), FcmOptions = this.FcmOptions?.CopyAndValidate(), }; var list = new List() @@ -153,11 +166,13 @@ internal Message CopyAndValidate() throw new ArgumentException("Malformed topic name."); } - // Copy and validate the child properties - copy.Notification = this.Notification?.CopyAndValidate(); - copy.Android = this.Android?.CopyAndValidate(); - copy.Webpush = this.Webpush?.CopyAndValidate(); - copy.Apns = this.Apns?.CopyAndValidate(); +#pragma warning disable CS0618 + if (copy.Android != null && copy.AndroidV2 != null) + { + throw new ArgumentException("Android and AndroidV2 are mutually exclusive properties on Message."); + } +#pragma warning restore CS0618 + return copy; } } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/MulticastMessage.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/MulticastMessage.cs index 62b506ef..884c2b4d 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/MulticastMessage.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/MulticastMessage.cs @@ -49,9 +49,16 @@ public sealed class MulticastMessage /// /// Gets or sets the Android-specific information to be included in the message. + /// Deprecated. Use instead. /// + [Obsolete("Deprecated. Use AndroidV2 instead.")] public AndroidConfig Android { get; set; } + /// + /// Gets or sets the Android-specific V2 information to be included in the message. + /// + public AndroidConfigV2 AndroidV2 { get; set; } + /// /// Gets or sets the Webpush-specific information to be included in the message. /// @@ -86,14 +93,17 @@ internal List GetMessageList() throw new ArgumentException("Total number of Tokens and FIDs must not exceed 500."); } +#pragma warning disable CS0618 var templateMessage = new Message { Android = this.Android?.CopyAndValidate(), + AndroidV2 = this.AndroidV2?.CopyAndValidate(), Apns = this.Apns?.CopyAndValidate(), Data = this.Data?.Copy(), Notification = this.Notification?.CopyAndValidate(), Webpush = this.Webpush?.CopyAndValidate(), }; +#pragma warning restore CS0618 var messages = new List(totalCount); @@ -101,17 +111,17 @@ internal List GetMessageList() { foreach (var token in tokensCopy) { +#pragma warning disable CS0618 var message = new Message { Android = templateMessage.Android, + AndroidV2 = templateMessage.AndroidV2, Apns = templateMessage.Apns, Data = templateMessage.Data, Notification = templateMessage.Notification, Webpush = templateMessage.Webpush, + Token = token, }; - -#pragma warning disable CS0618 - message.Token = token; #pragma warning restore CS0618 messages.Add(message); @@ -122,15 +132,18 @@ internal List GetMessageList() { foreach (var fid in fidsCopy) { +#pragma warning disable CS0618 var message = new Message { Android = templateMessage.Android, + AndroidV2 = templateMessage.AndroidV2, Apns = templateMessage.Apns, Data = templateMessage.Data, Notification = templateMessage.Notification, Webpush = templateMessage.Webpush, Fid = fid, }; +#pragma warning restore CS0618 messages.Add(message); }