diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs index 0fd03535..b211aa0b 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs @@ -105,6 +105,13 @@ public void MessageDeserialization() { RestrictedPackageName = "test-pkg-name", }, + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "test-alert", + }, + }, Webpush = new WebpushConfig() { Data = new Dictionary(){{ "key", "value" }}, @@ -118,6 +125,7 @@ public void MessageDeserialization() Assert.Equal(original.Notification.Body, copy.Notification.Body); Assert.Equal( original.Android.RestrictedPackageName, copy.Android.RestrictedPackageName); + Assert.Equal(original.Apns.Aps.AlertString, copy.Apns.Aps.AlertString); Assert.Equal(original.Webpush.Data, copy.Webpush.Data); } @@ -130,6 +138,7 @@ public void MessageCopy() Data = new Dictionary(), Notification = new Notification(), Android = new AndroidConfig(), + Apns = new ApnsConfig(), Webpush = new WebpushConfig(), }; var copy = original.CopyAndValidate(); @@ -137,6 +146,7 @@ public void MessageCopy() Assert.NotSame(original.Data, copy.Data); Assert.NotSame(original.Notification, copy.Notification); Assert.NotSame(original.Android, copy.Android); + Assert.NotSame(original.Apns, copy.Apns); Assert.NotSame(original.Webpush, copy.Webpush); } @@ -799,6 +809,754 @@ public void WebpushNotificationDuplicateKeys() Assert.Throws(() => message.CopyAndValidate()); } + [Fact] + public void ApnsConfig() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Headers = new Dictionary() + { + {"k1", "v1"}, + {"k2", "v2"}, + }, + Aps = new Aps() + { + AlertString = "alert-text", + Badge = 0, + Category = "test-category", + ContentAvailable = true, + MutableContent = true, + Sound = "sound-file", + ThreadId = "test-thread", + CustomData = new Dictionary() + { + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + }, + }, + CustomData = new Dictionary() + { + {"custom-key3", "custom-data"}, + {"custom-key4", true}, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "headers", new JObject() + { + {"k1", "v1"}, + {"k2", "v2"}, + } + }, + { + "payload", new JObject() + { + { + "aps", new JObject() + { + {"alert", "alert-text"}, + {"badge", 0}, + {"category", "test-category"}, + {"content-available", 1}, + {"mutable-content", 1}, + {"sound", "sound-file"}, + {"thread-id", "test-thread"}, + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + } + }, + {"custom-key3", "custom-data"}, + {"custom-key4", true}, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsConfigMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps(), + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + {"aps", new JObject()}, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsConfigDeserialization() + { + var original = new ApnsConfig() + { + Headers = new Dictionary() + { + {"k1", "v1"}, + {"k2", "v2"}, + }, + Aps = new Aps() + { + AlertString = "alert-text", + }, + CustomData = new Dictionary() + { + {"custom-key3", "custom-data"}, + {"custom-key4", true}, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Headers, copy.Headers); + Assert.Equal(original.CustomData, copy.CustomData); + Assert.Equal(original.Aps.AlertString, copy.Aps.AlertString); + } + + [Fact] + public void ApnsConfigCopy() + { + var original = new ApnsConfig() + { + Headers = new Dictionary(), + Aps = new Aps(), + CustomData = new Dictionary(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Headers, copy.Headers); + Assert.NotSame(original.Aps, copy.Aps); + Assert.NotSame(original.CustomData, copy.CustomData); + } + + [Fact] + public void ApnsConfigCustomApsDeserialization() + { + var original = new ApnsConfig() + { + Headers = new Dictionary() + { + {"k1", "v1"}, + {"k2", "v2"}, + }, + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + {"alert", "alert-text"}, + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + } + }, + {"custom-key3", "custom-data"}, + {"custom-key4", true}, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Headers, copy.Headers); + original.CustomData.Remove("aps"); + Assert.Equal(original.CustomData, copy.CustomData); + Assert.Equal("alert-text", copy.Aps.AlertString); + var customApsData = new Dictionary() + { + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + }; + Assert.Equal(customApsData, copy.Aps.CustomData); + } + + [Fact] + public void ApnsCriticalSound() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Critical = true, + Volume = 0.5, + }, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "sound", new JObject() + { + {"name", "default"}, + {"critical", 1}, + {"volume", 0.5}, + } + }, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCriticalSoundMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound(){Name = "default"}, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "sound", new JObject() + { + {"name", "default"}, + } + }, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCriticalSoundDeserialization() + { + var original = new CriticalSound() + { + Name = "default", + Volume = 0.5, + Critical = true, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Name, copy.Name); + Assert.Equal(original.Volume.Value, copy.Volume.Value); + Assert.Equal(original.Critical, copy.Critical); + } + + [Fact] + public void ApnsApsAlert() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + ActionLocKey = "action-key", + Body = "test-body", + LaunchImage = "test-image", + LocArgs = new List(){"arg1", "arg2"}, + LocKey = "loc-key", + Subtitle = "test-subtitle", + SubtitleLocArgs = new List(){"arg3", "arg4"}, + SubtitleLocKey = "subtitle-key", + Title = "test-title", + TitleLocArgs = new List(){"arg5", "arg6"}, + TitleLocKey = "title-key", + }, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "alert", new JObject() + { + {"action-loc-key", "action-key"}, + {"body", "test-body"}, + {"launch-image", "test-image"}, + {"loc-args", new JArray(){"arg1", "arg2"}}, + {"loc-key", "loc-key"}, + {"subtitle", "test-subtitle"}, + {"subtitle-loc-args", new JArray(){"arg3", "arg4"}}, + {"subtitle-loc-key", "subtitle-key"}, + {"title", "test-title"}, + {"title-loc-args", new JArray(){"arg5", "arg6"}}, + {"title-loc-key", "title-key"}, + } + }, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsApsAlertMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert(), + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "alert", new JObject(){} + }, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApsAlertDeserialization() + { + var original = new ApsAlert() + { + ActionLocKey = "action-key", + Body = "test-body", + LaunchImage = "test-image", + LocArgs = new List(){"arg1", "arg2"}, + LocKey = "loc-key", + Subtitle = "test-subtitle", + SubtitleLocArgs = new List(){"arg3", "arg4"}, + SubtitleLocKey = "subtitle-key", + Title = "test-title", + TitleLocArgs = new List(){"arg5", "arg6"}, + TitleLocKey = "title-key", + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.ActionLocKey, copy.ActionLocKey); + Assert.Equal(original.Body, copy.Body); + Assert.Equal(original.LaunchImage, copy.LaunchImage); + Assert.Equal(original.LocArgs, copy.LocArgs); + Assert.Equal(original.LocKey, copy.LocKey); + Assert.Equal(original.Subtitle, copy.Subtitle); + Assert.Equal(original.SubtitleLocArgs, copy.SubtitleLocArgs); + Assert.Equal(original.SubtitleLocKey, copy.SubtitleLocKey); + Assert.Equal(original.Title, copy.Title); + Assert.Equal(original.TitleLocArgs, copy.TitleLocArgs); + Assert.Equal(original.TitleLocKey, copy.TitleLocKey); + } + + [Fact] + public void ApsAlertCopy() + { + var original = new ApsAlert() + { + LocArgs = new List(){"arg1", "arg2"}, + LocKey = "loc-key", + SubtitleLocArgs = new List(){"arg3", "arg4"}, + SubtitleLocKey = "subtitle-key", + TitleLocArgs = new List(){"arg5", "arg6"}, + TitleLocKey = "title-key", + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.LocArgs, copy.LocArgs); + Assert.NotSame(original.SubtitleLocArgs, copy.SubtitleLocArgs); + Assert.NotSame(original.TitleLocArgs, copy.TitleLocArgs); + } + + [Fact] + public void ApnsApsAlertInvalidTitleLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + TitleLocArgs = new List(){"arg1", "arg2"}, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsApsAlertInvalidSubtitleLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + SubtitleLocArgs = new List(){"arg1", "arg2"}, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsApsAlertInvalidLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + LocArgs = new List(){"arg1", "arg2"}, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsCustomApsWithStandardProperties() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + {"alert", "alert-text"}, + {"badge", 42}, + } + }, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + {"alert", "alert-text"}, + {"badge", 42}, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCustomApsWithCustomProperties() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + } + }, + }, + }, + }; + var expected = new JObject() + { + {"topic", "test-topic"}, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + {"custom-key1", "custom-data"}, + {"custom-key2", true}, + } + }, + } + }, + } + }, + }; + AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsNoAps() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + {"test", "custom-data"}, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateAps() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "alert-text", + }, + CustomData = new Dictionary() + { + {"aps", "custom-data"}, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApsDuplicateKeys() + { + var aps = new Aps() + { + AlertString = "alert-text", + CustomData = new Dictionary() + { + {"alert", "other-alert-text"}, + }, + }; + Assert.Throws(() => aps.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateApsAlerts() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "alert-text", + Alert = new ApsAlert() + { + Body = "other-alert-text", + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateApsSounds() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Sound = "default", + CriticalSound = new CriticalSound() + { + Name = "other=sound", + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundNoName() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound(), + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundVolumeTooLow() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Volume = -0.1, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundVolumeTooHigh() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Volume = 1.1, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + private void AssertJsonEquals(JObject expected, Message actual) { var json = NewtonsoftJsonSerializer.Instance.Serialize(actual.CopyAndValidate()); diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs index 417efe70..24e546fa 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs @@ -135,21 +135,15 @@ internal AndroidNotification CopyAndValidate() }; if (copy.Color != null && !Regex.Match(copy.Color, "^#[0-9a-fA-F]{6}$").Success) { - throw new ArgumentException("Color must be in the form #RRGGBB"); + throw new ArgumentException("Color must be in the form #RRGGBB."); } - if (copy.TitleLocArgs != null && copy.TitleLocArgs.Any()) + if (copy.TitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.TitleLocKey)) { - if (string.IsNullOrEmpty(copy.TitleLocKey)) - { - throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs"); - } + throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs."); } - if (copy.BodyLocArgs != null && copy.BodyLocArgs.Any()) + if (copy.BodyLocArgs?.Any() == true && string.IsNullOrEmpty(copy.BodyLocKey)) { - if (string.IsNullOrEmpty(copy.BodyLocKey)) - { - throw new ArgumentException("BodyLocKey is required when specifying BodyLocArgs"); - } + throw new ArgumentException("BodyLocKey is required when specifying BodyLocArgs."); } return copy; } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs new file mode 100644 index 00000000..158016d2 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs @@ -0,0 +1,135 @@ +// Copyright 2018, 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.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the APNS-specific options that can be included in a . Refer + /// to + /// APNs documentation for various headers and payload fields supported by APNS. + /// + public sealed class ApnsConfig + { + private ApnsPayload _payload = new ApnsPayload(); + + /// + /// A collection of APNs headers. + /// + [JsonProperty("headers")] + public IReadOnlyDictionary Headers { get; set; } + + /// + /// The aps dictionary to be included in the APNs payload. + /// + [JsonIgnore] + public Aps Aps + { + get + { + return Payload.Aps; + } + set + { + Payload.Aps = value; + } + } + + /// + /// APNs payload as accepted by the FCM backend servers. + /// + [JsonProperty("payload")] + private ApnsPayload Payload + { + get + { + if (_payload.Aps != null && _payload.CustomData?.ContainsKey("aps") == true) + { + throw new ArgumentException("Multiple specifications for ApnsConfig key: aps"); + } + return _payload; + } + set + { + _payload = value; + } + } + + /// + /// A collection of arbitrary key-value data to be included in the APNs payload. + /// + [JsonIgnore] + public IDictionary CustomData + { + get + { + return Payload.CustomData; + } + set + { + Payload.CustomData = value; + } + } + + /// + /// Copies this APNs config, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal ApnsConfig CopyAndValidate() + { + var copy = new ApnsConfig() + { + Headers = this.Headers?.Copy(), + Payload = this.Payload.CopyAndValidate(), + }; + return copy; + } + } + + /// + /// The APNs payload object as expected by the FCM backend service. + /// + internal sealed class ApnsPayload + { + [JsonProperty("aps")] + internal Aps Aps { get; set; } + + [JsonExtensionData] + internal IDictionary CustomData { get; set; } + + /// + /// Copies this APNs payload, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal ApnsPayload CopyAndValidate() + { + var copy = new ApnsPayload() + { + CustomData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value), + }; + var aps = this.Aps; + if (aps == null && copy.CustomData?.ContainsKey("aps") == false) + { + throw new ArgumentException("Aps dictionary is required in ApnsConfig"); + } + copy.Aps = aps?.CopyAndValidate(); + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs new file mode 100644 index 00000000..7c64dae9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs @@ -0,0 +1,253 @@ +// Copyright 2018, 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.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the + /// aps dictionary that is part of every APNs message. + /// + public sealed class Aps + { + private static readonly NewtonsoftJsonSerializer Serializer = NewtonsoftJsonSerializer.Instance; + + /// + /// An advanced alert configuration to be included in the message. It is an error to set + /// both and properties together. + /// + [JsonIgnore] + public ApsAlert Alert { get; set; } + + /// + /// The alert text to be included in the message. To specify a more advanced alert + /// configuration, use the property instead. It is an error to set + /// both and properties together. + /// + [JsonIgnore] + public string AlertString { get; set; } + + [JsonProperty("alert")] + private object AlertObject + { + get + { + object alert = this.AlertString; + if (string.IsNullOrEmpty(alert as string)) + { + alert = this.Alert; + } + else if (this.Alert != null) + { + throw new ArgumentException( + "Multiple specifications for alert (Alert and AlertString"); + } + return alert; + } + set + { + if (value == null) + { + return; + } + else if (value.GetType() == typeof(string)) + { + AlertString = (string) value; + } + else if (value.GetType() == typeof(ApsAlert)) + { + Alert = (ApsAlert) value; + } + else + { + var json = Serializer.Serialize(value); + Alert = Serializer.Deserialize(json); + } + } + } + + /// + /// The badge to be displayed with the message. Set to 0 to remove the badge. When not + /// specified, the badge will remain unchanged. + /// + [JsonProperty("badge")] + public int? Badge { get; set; } + + /// + /// The name of a sound file in your app's main bundle or in the + /// Library/Sounds folder of your app's container directory. Specify the + /// string default to play the system sound. It is an error to set both + /// and properties together. + /// + [JsonIgnore] + public string Sound { get; set; } + + /// + /// The critical alert sound to be played with the message. It is an error to set both + /// and properties together. + /// + [JsonIgnore] + public CriticalSound CriticalSound { get; set; } + + [JsonProperty("sound")] + private object SoundObject + { + get + { + object sound = this.Sound; + if (string.IsNullOrEmpty(sound as string)) + { + sound = this.CriticalSound; + } + else if (this.CriticalSound != null) + { + throw new ArgumentException( + "Multiple specifications for sound (CriticalSound and Sound"); + } + return sound; + } + set + { + if (value == null) + { + return; + } + else if (value.GetType() == typeof(string)) + { + Sound = (string) value; + } + else if (value.GetType() == typeof(CriticalSound)) + { + CriticalSound = (CriticalSound) value; + } + else + { + var json = Serializer.Serialize(value); + CriticalSound = Serializer.Deserialize(json); + } + } + } + + /// + /// Specifies whether to configure a background update notification. + /// + [JsonIgnore] + public bool ContentAvailable { get; set; } + + /// + /// Integer representation of the property, which is how + /// APNs expects it. + /// + [JsonProperty("content-available")] + private int? ContentAvailableInt + { + get + { + return ContentAvailable ? 1 : (int?) null; + } + set + { + ContentAvailable = (value == 1); + } + } + + /// + /// Specifies whether to set the mutable-content property on the message. When + /// set, this property allows clients to modify the notification via app extensions. + /// + [JsonIgnore] + public bool MutableContent { get; set; } + + /// + /// Integer representation of the property, which is how + /// APNs expects it. + /// + [JsonProperty("mutable-content")] + private int? MutableContentInt + { + get + { + return MutableContent ? 1 : (int?) null; + } + set + { + MutableContent = (value == 1); + } + } + + /// + /// The type of the notification. + /// + [JsonProperty("category")] + public string Category { get; set; } + + /// + /// An app-specific identifier for grouping notifications. + /// + [JsonProperty("thread-id")] + public string ThreadId { get; set; } + + /// + /// A collection of arbitrary key-value data to be included in the aps + /// dictionary. This is exposed as an to support + /// correct deserialization of custom properties. + /// + [JsonExtensionData] + public IDictionary CustomData { get; set; } + + /// + /// Copies this Aps dictionary, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM and APNs services. + /// + internal Aps CopyAndValidate() + { + var copy = new Aps + { + AlertObject = this.AlertObject, + Badge = this.Badge, + ContentAvailable = this.ContentAvailable, + MutableContent = this.MutableContent, + Category = this.Category, + SoundObject = this.SoundObject, + ThreadId = this.ThreadId, + }; + + var customData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value); + if (customData?.Count > 0) + { + var serializer = NewtonsoftJsonSerializer.Instance; + var json = serializer.Serialize(copy); + var standardProperties = serializer.Deserialize>(json); + var duplicates = customData.Keys + .Where(customKey => standardProperties.ContainsKey(customKey)) + .ToList(); + if (duplicates.Any()) + { + throw new ArgumentException( + $"Multiple specifications for Aps keys: {string.Join(",", duplicates)}"); + } + copy.CustomData = customData; + } + + copy.Alert = copy.Alert?.CopyAndValidate(); + copy.CriticalSound = copy.CriticalSound?.CopyAndValidate(); + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs new file mode 100644 index 00000000..c48c6012 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs @@ -0,0 +1,139 @@ +// Copyright 2018, 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.Linq; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the + /// alert property that can be included in the aps dictionary of an APNs + /// payload. + /// + public sealed class ApsAlert + { + /// + /// The title of the alert. When provided, overrides the title set via + /// . + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// The subtitle of the alert. + /// + [JsonProperty("subtitle")] + public string Subtitle { get; set; } + + /// + /// The body of the alert. When provided, overrides the body set via + /// . + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// The key of the body string in the app's string resources to use to localize the body + /// text. + /// + [JsonProperty("loc-key")] + public string LocKey { get; set; } + + /// + /// Resource key strings that will be used in place of the format specifiers in + /// . + /// + [JsonProperty("loc-args")] + public IEnumerable LocArgs { get; set; } + + /// + /// 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; } + + /// + /// Resource key strings that will be used in place of the format specifiers in + /// . + /// + [JsonProperty("title-loc-args")] + public IEnumerable TitleLocArgs { get; set; } + + /// + /// The key of the subtitle string in the app's string resources to use to localize the + /// subtitle text. + /// + [JsonProperty("subtitle-loc-key")] + public string SubtitleLocKey { get; set; } + + /// + /// Resource key strings that will be used in place of the format specifiers in + /// . + /// + [JsonProperty("subtitle-loc-args")] + public IEnumerable SubtitleLocArgs { get; set; } + + /// + /// The key of the text in the app's string resources to use to localize the action button + /// text. + /// + [JsonProperty("action-loc-key")] + public string ActionLocKey { get; set; } + + /// + /// The launch image for the notification action. + /// + [JsonProperty("launch-image")] + public string LaunchImage { get; set; } + + /// + /// Copies this alert dictionary, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM and APNs services. + /// + internal ApsAlert CopyAndValidate() + { + var copy = new ApsAlert() + { + Title = this.Title, + Subtitle = this.Subtitle, + Body = this.Body, + LocKey = this.LocKey, + LocArgs = this.LocArgs?.ToList(), + TitleLocKey = this.TitleLocKey, + TitleLocArgs = this.TitleLocArgs?.ToList(), + SubtitleLocKey = this.SubtitleLocKey, + SubtitleLocArgs = this.SubtitleLocArgs?.ToList(), + ActionLocKey = this.ActionLocKey, + LaunchImage = this.LaunchImage, + }; + if (copy.TitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.TitleLocKey)) + { + throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs."); + } + if (copy.SubtitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.SubtitleLocKey)) + { + throw new ArgumentException("SubtitleLocKey is required when specifying SubtitleLocArgs."); + } + if (copy.LocArgs?.Any() == true && string.IsNullOrEmpty(copy.LocKey)) + { + throw new ArgumentException("LocKey is required when specifying LocArgs."); + } + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs new file mode 100644 index 00000000..de59f1d0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs @@ -0,0 +1,91 @@ +// Copyright 2018, 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 Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// The sound configuration for APNs critical alerts. + /// + public sealed class CriticalSound + { + /// + /// Sets the critical alert flag on the sound configuration. + /// + [JsonIgnore] + public bool Critical { get; set; } + + /// + /// Integer representation of the property, which is how + /// APNs expects it. + /// + [JsonProperty("critical")] + private int? CriticalInt + { + get + { + if (Critical) + { + return 1; + } + return null; + } + set + { + Critical = (value == 1); + } + } + + /// + /// The name of a sound file in your app's main bundle or in the + /// Library/Sounds folder of your app's container directory. Specify the + /// string default to play the system sound. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The volume for the critical alert's sound. Must be a value between 0.0 (silent) and + /// 1.0 (full volume). + /// + [JsonProperty("volume")] + public double? Volume { get; set; } + + /// + /// Copies this critical sound configuration, and validates the content of it to ensure + /// that it can be serialized into the JSON format expected by the FCM and APNs services. + /// + internal CriticalSound CopyAndValidate() + { + var copy = new CriticalSound() + { + Critical = this.Critical, + Name = this.Name, + Volume = this.Volume, + }; + if (string.IsNullOrEmpty(copy.Name)) + { + throw new ArgumentException("Name must be specified for CriticalSound"); + } + if (copy.Volume < 0 || copy.Volume > 1) + { + throw new ArgumentException("Volume of CriticalSound must be in the interval [0, 1]"); + } + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs index 6635761e..64c226c8 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs @@ -95,6 +95,12 @@ private string UnprefixedTopic [JsonProperty("webpush")] public WebpushConfig Webpush { get; set; } + /// + /// The APNs-specific information to be included in the message. + /// + [JsonProperty("apns")] + public ApnsConfig Apns { get; set; } + /// /// Copies this message, and validates the content of it to ensure that it can be /// serialized into the JSON format expected by the FCM service. Each property is copied @@ -131,6 +137,7 @@ internal Message CopyAndValidate() copy.Notification = this.Notification?.CopyAndValidate(); copy.Android = this.Android?.CopyAndValidate(); copy.Webpush = this.Webpush?.CopyAndValidate(); + copy.Apns = this.Apns?.CopyAndValidate(); return copy; } } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs index 10dd7fb3..9c9b1f81 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs @@ -100,8 +100,9 @@ private string DirectionString Direction = Messaging.Direction.RightToLeft; return; default: - throw new FirebaseException($"Invalid direction value: {value}. Only " - + "'auto', 'rtl' and 'ltr' are allowed."); + throw new FirebaseException( + $"Invalid direction value: {value}. Only 'auto', 'rtl' and 'ltr' " + + "are allowed."); } } } @@ -195,21 +196,20 @@ internal WebpushNotification CopyAndValidate() Data = this.Data, }; - var customData = this.CustomData; + var customData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value); if (customData?.Count > 0) { var serializer = NewtonsoftJsonSerializer.Instance; - // Serialize the notification without CustomData for validation. var json = serializer.Serialize(copy); - var dict = serializer.Deserialize>(json); - customData = new Dictionary(customData); - foreach (var entry in customData) + var standardProperties = serializer.Deserialize>(json); + var duplicates = customData.Keys + .Where(customKey => standardProperties.ContainsKey(customKey)) + .ToList(); + if (duplicates.Any()) { - if (dict.ContainsKey(entry.Key)) - { - throw new ArgumentException( - $"Multiple specifications for WebpushNotification key: {entry.Key}"); - } + throw new ArgumentException( + "Multiple specifications for WebpushNotification keys: " + + string.Join(",", duplicates)); } copy.CustomData = customData; }