apple_apns/
payload.rs

1use serde::{
2    de::{self, MapAccess, Visitor},
3    ser::{SerializeMap, SerializeStruct},
4    Deserialize, Serialize,
5};
6use serde_plain::{derive_display_from_serialize, derive_fromstr_from_deserialize};
7use serde_with::{serde_as, skip_serializing_none, BoolFromInt};
8
9fn is_false(v: &bool) -> bool {
10    !v
11}
12
13/// Put the JSON payload with the notification’s content into the body of your
14/// request. The JSON payload must not be compressed and is limited to a maximum
15/// size of 4 KB (4096 bytes). For a Voice over Internet Protocol (VoIP)
16/// notification, the maximum size is 5 KB (5120 bytes).
17#[serde_as]
18#[skip_serializing_none]
19#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub struct Payload<T = ()>
22where
23    T: Serialize,
24{
25    /// Apple-defined keys.
26    pub aps: Aps,
27
28    /// Additional data to send.
29    #[serde(flatten)]
30    pub user_info: Option<T>,
31}
32
33/// Apple-defined keys.
34#[serde_as]
35#[skip_serializing_none]
36#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
37#[serde(rename_all = "kebab-case")]
38pub struct Aps {
39    /// The information for displaying an alert.
40    pub alert: Option<Alert>,
41
42    /// The number to display in a badge on your app’s icon. Specify `0` to
43    /// remove the current badge, if any.
44    pub badge: Option<u32>,
45
46    /// The name of a sound file in your app’s main bundle or in the
47    /// `Library/Sounds` folder of your app’s container directory or a
48    /// dictionary that contains sound information for critical alerts.
49    pub sound: Option<Sound>,
50
51    /// An app-specific identifier for grouping related notifications. This
52    /// value corresponds to the
53    /// [`threadIdentifier`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier)
54    /// property in the `UNNotificationContent` object.
55    pub thread_id: Option<String>,
56
57    /// The notification’s type. This string must correspond to the
58    /// [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcategory/1649276-identifier)
59    /// of one of the `UNNotificationCategory` objects you register at launch
60    /// time. See [Declaring Your Actionable Notification
61    /// Types](https://developer.apple.com/documentation/usernotifications/declaring_your_actionable_notification_types).
62    pub category: Option<String>,
63
64    /// The background notification flag. To perform a silent background update,
65    /// specify the value `1` and don’t include the `alert`, `badge`, or `sound`
66    /// keys in your payload. See [Pushing Background Updates to Your
67    /// App](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app).
68    #[serde(default, skip_serializing_if = "is_false")]
69    #[serde_as(as = "BoolFromInt")]
70    pub content_available: bool,
71
72    /// The notification service app extension flag. If the value is `1`, the
73    /// system passes the notification to your notification service app
74    /// extension before delivery. Use your extension to modify the
75    /// notification’s content. See [Modifying Content in Newly Delivered
76    /// Notifications](https://developer.apple.com/documentation/usernotifications/modifying_content_in_newly_delivered_notifications).
77    #[serde(default, skip_serializing_if = "is_false")]
78    #[serde_as(as = "BoolFromInt")]
79    pub mutable_content: bool,
80
81    /// The identifier of the window brought forward. The value of this key will
82    /// be populated on the
83    /// [`UNNotificationContent`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent)
84    /// object created from the push payload. Access the value using the
85    /// [`UNNotificationContent`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent)
86    /// object’s
87    /// [`targetContentIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/3235764-targetcontentidentifier)
88    /// property.
89    pub target_content_id: Option<String>,
90
91    /// The importance and delivery timing of a notification. The string values
92    /// `passive`, `active`, `time-sensitive`, or `critical` correspond to the
93    /// [`UNNotificationInterruptionLevel`](https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel)
94    /// enumeration cases.
95    pub interruption_level: Option<InterruptionLevel>,
96
97    /// The relevance score, a number between `0` and `1`, that the system uses
98    /// to sort the notifications from your app. The highest score gets featured
99    /// in the notification summary. See
100    /// [`relevanceScore`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/3821031-relevancescore).
101    pub relevance_score: Option<f64>,
102}
103
104/// Alert options.
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
106pub struct Alert {
107    /// The title of the notification. Apple Watch displays this string in
108    /// the short look notification interface. Specify a string that’s
109    /// quickly understood by the user.
110    pub title: Option<String>,
111
112    /// Additional information that explains the purpose of the
113    /// notification.
114    pub subtitle: Option<String>,
115
116    /// The content of the alert message.
117    pub body: Option<String>,
118
119    /// The name of the launch image file to display. If the user chooses to
120    /// launch your app, the contents of the specified image or storyboard
121    /// file are displayed instead of your app’s normal launch image.
122    pub launch_image: Option<String>,
123
124    /// The key for a localized `title` string. Specify this key instead of
125    /// the title key to retrieve the title from your app’s
126    /// `Localizable.strings` files. The value must contain the name of a
127    /// key in your strings file.
128    pub title_loc_key: Option<String>,
129
130    /// An array of strings containing replacement values for variables in
131    /// your title string. Each `%@` character in the string specified by
132    /// the `title-loc-key` is replaced by a value from this array. The
133    /// first item in the array replaces the first instance of the `%@`
134    /// character in the string, the second item replaces the second
135    /// instance, and so on.
136    pub title_loc_args: Option<Vec<String>>,
137
138    /// The key for a localized `subtitle` string. Use this key, instead of
139    /// the subtitle key, to retrieve the subtitle from your app’s
140    /// `Localizable.strings` file. The value must contain the name of a key
141    /// in your strings file.
142    pub subtitle_loc_key: Option<String>,
143
144    /// An array of strings containing replacement values for variables in
145    /// your title string. Each `%@` character in the string specified by
146    /// `subtitle-loc-key` is replaced by a value from this array. The first
147    /// item in the array replaces the first instance of the `%@` character in
148    /// the string, the second item replaces the second instance, and so on.
149    pub subtitle_loc_args: Option<Vec<String>>,
150
151    /// The key for a localized message string. Use this key, instead of the
152    /// body key, to retrieve the message text from your app’s
153    /// `Localizable.strings` file. The value must contain the name of a key
154    /// in your strings file.
155    pub loc_key: Option<String>,
156
157    /// An array of strings containing replacement values for variables in
158    /// your message text. Each `%@` character in the string specified by
159    /// `loc-key` is replaced by a value from this array. The first item in
160    /// the array replaces the first instance of the `%@` character in the
161    /// string, the second item replaces the second instance, and so on.
162    pub loc_args: Option<Vec<String>>,
163}
164
165impl From<String> for Alert {
166    fn from(value: String) -> Self {
167        Alert {
168            body: Some(value),
169            ..Default::default()
170        }
171    }
172}
173
174impl<'a> From<&'a str> for Alert {
175    fn from(value: &'a str) -> Self {
176        Alert {
177            body: Some(value.to_string()),
178            ..Default::default()
179        }
180    }
181}
182
183impl<'de> Deserialize<'de> for Alert {
184    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
185    where
186        D: serde::Deserializer<'de>,
187    {
188        struct AlertVisitor;
189
190        impl<'de> Visitor<'de> for AlertVisitor {
191            type Value = Alert;
192
193            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
194                write!(formatter, "an alert struct")
195            }
196
197            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
198            where
199                E: de::Error,
200            {
201                Ok(Alert {
202                    body: Some(v.into()),
203                    ..Default::default()
204                })
205            }
206
207            fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
208            where
209                E: de::Error,
210            {
211                Ok(Alert {
212                    body: Some(v),
213                    ..Default::default()
214                })
215            }
216
217            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
218            where
219                A: MapAccess<'de>,
220            {
221                let mut alert = Alert::default();
222
223                while let Some(key) = map.next_key::<&str>()? {
224                    match key {
225                        "title" => alert.title = map.next_value()?,
226                        "title-loc-key" => alert.title_loc_key = map.next_value()?,
227                        "title-loc-args" => alert.title_loc_args = map.next_value()?,
228                        "subtitle" => alert.subtitle = map.next_value()?,
229                        "subtitle-loc-key" => alert.subtitle_loc_key = map.next_value()?,
230                        "subtitle-loc-args" => alert.subtitle_loc_args = map.next_value()?,
231                        "body" => alert.body = map.next_value()?,
232                        "loc-key" => alert.loc_key = map.next_value()?,
233                        "loc-args" => alert.loc_args = map.next_value()?,
234                        "launch-image" => alert.launch_image = map.next_value()?,
235                        field => {
236                            return Err(de::Error::unknown_field(
237                                field,
238                                &[
239                                    "title",
240                                    "title-loc-key",
241                                    "title-loc-args",
242                                    "subtitle",
243                                    "subtitle-loc-key",
244                                    "subtitle-loc-args",
245                                    "body",
246                                    "loc-key",
247                                    "loc-args",
248                                    "launch-image",
249                                ],
250                            ));
251                        }
252                    }
253                }
254
255                Ok(alert)
256            }
257        }
258
259        deserializer.deserialize_any(AlertVisitor)
260    }
261}
262
263impl Serialize for Alert {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: serde::Serializer,
267    {
268        if self.title.is_none()
269            && self.title_loc_key.is_none()
270            && self.title_loc_args.is_none()
271            && self.subtitle.is_none()
272            && self.subtitle_loc_key.is_none()
273            && self.subtitle_loc_args.is_none()
274            && self.loc_key.is_none()
275            && self.loc_args.is_none()
276            && self.launch_image.is_none()
277        {
278            return serializer.serialize_str(self.body.as_deref().unwrap_or_default());
279        }
280
281        let mut len = 0;
282
283        // title
284        if self.title_loc_key.is_some() {
285            len += 1;
286            if self.title_loc_args.is_some() {
287                len += 1;
288            }
289        } else if self.title.is_some() {
290            len += 1;
291        }
292
293        // subtitle
294        if self.subtitle_loc_key.is_some() {
295            len += 1;
296            if self.subtitle_loc_args.is_some() {
297                len += 1;
298            }
299        } else if self.subtitle.is_some() {
300            len += 1;
301        }
302
303        // body
304        if self.loc_key.is_some() {
305            len += 1;
306            if self.loc_args.is_some() {
307                len += 1;
308            }
309        } else {
310            len += 1;
311        }
312
313        // launch-image
314        if self.launch_image.is_some() {
315            len += 1;
316        }
317
318        let mut alert = serializer.serialize_map(Some(len))?;
319
320        // title
321        if let Some(title_loc_key) = &self.title_loc_key {
322            alert.serialize_entry("title-loc-key", title_loc_key)?;
323            if let Some(title_loc_args) = &self.title_loc_args {
324                alert.serialize_entry("title-loc-args", title_loc_args)?;
325            }
326        } else if let Some(title) = &self.title {
327            alert.serialize_entry("title", title)?;
328        }
329
330        // subtitle
331        if let Some(subtitle_loc_key) = &self.subtitle_loc_key {
332            alert.serialize_entry("subtitle-loc-key", subtitle_loc_key)?;
333            if let Some(subtitle_loc_args) = &self.subtitle_loc_args {
334                alert.serialize_entry("subtitle-loc-args", subtitle_loc_args)?;
335            }
336        } else if let Some(subtitle) = &self.subtitle {
337            alert.serialize_entry("subtitle", subtitle)?;
338        }
339
340        // body
341        if let Some(loc_key) = &self.loc_key {
342            alert.serialize_entry("loc-key", loc_key)?;
343            if let Some(loc_args) = &self.loc_args {
344                alert.serialize_entry("loc-args", loc_args)?;
345            }
346        } else {
347            alert.serialize_entry("body", &self.body)?;
348        }
349
350        // launch-image
351        if let Some(launch_image) = &self.launch_image {
352            alert.serialize_entry("launch-image", launch_image)?;
353        }
354
355        alert.end()
356    }
357}
358
359/// Sound options.
360#[derive(Clone, Debug, PartialEq)]
361pub struct Sound {
362    /// The critical alert flag. Set to `1` to enable the critical alert.
363    pub critical: bool,
364
365    /// The name of a sound file in your app’s main bundle or in the
366    /// `Library/Sounds` folder of your app’s container directory. Specify
367    /// the string `default` to play the system sound. For information about
368    /// how to prepare sounds, see
369    /// [`UNNotificationSound`](https://developer.apple.com/documentation/usernotifications/unnotificationsound).
370    pub name: String,
371
372    /// The volume for the critical alert’s sound. Set this to a value
373    /// between `0` (silent) and `1` (full volume).
374    pub volume: f64,
375}
376
377impl Default for Sound {
378    fn default() -> Self {
379        Self {
380            critical: false,
381            name: "default".into(),
382            volume: 1.,
383        }
384    }
385}
386
387impl From<String> for Sound {
388    fn from(value: String) -> Self {
389        Self {
390            critical: false,
391            name: value,
392            volume: 0.,
393        }
394    }
395}
396
397impl<'a> From<&'a str> for Sound {
398    fn from(value: &'a str) -> Self {
399        Self {
400            critical: false,
401            name: value.to_string(),
402            volume: 0.,
403        }
404    }
405}
406
407impl<'de> Deserialize<'de> for Sound {
408    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409    where
410        D: serde::Deserializer<'de>,
411    {
412        struct SoundVisitor;
413
414        impl<'de> Visitor<'de> for SoundVisitor {
415            type Value = Sound;
416
417            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
418                write!(formatter, "a sound struct")
419            }
420
421            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
422            where
423                E: de::Error,
424            {
425                Ok(Sound {
426                    critical: false,
427                    name: v.into(),
428                    volume: 0.,
429                })
430            }
431
432            fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
433            where
434                E: de::Error,
435            {
436                Ok(Sound {
437                    critical: false,
438                    name: v,
439                    volume: 0.,
440                })
441            }
442
443            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
444            where
445                A: MapAccess<'de>,
446            {
447                let mut sound = Sound::default();
448                let mut match_critical = false;
449                let mut match_name = false;
450                let mut match_volume = false;
451
452                while let Some(key) = map.next_key::<&str>()? {
453                    match key {
454                        "critical" => {
455                            let critical: i64 = map.next_value()?;
456                            sound.critical = critical != 0;
457                            match_critical = true;
458                        }
459                        "name" => {
460                            sound.name = map.next_value()?;
461                            match_name = true;
462                        }
463                        "volume" => {
464                            sound.volume = map.next_value()?;
465                            match_volume = true;
466                        }
467                        field => {
468                            return Err(de::Error::unknown_field(
469                                field,
470                                &["critical", "name", "volume"],
471                            ));
472                        }
473                    }
474                }
475
476                if !match_critical {
477                    return Err(de::Error::missing_field("critical"));
478                }
479                if !match_name {
480                    return Err(de::Error::missing_field("name"));
481                }
482                if !match_volume {
483                    return Err(de::Error::missing_field("volume"));
484                }
485
486                Ok(sound)
487            }
488        }
489
490        deserializer.deserialize_any(SoundVisitor)
491    }
492}
493
494impl Serialize for Sound {
495    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
496    where
497        S: serde::Serializer,
498    {
499        if self.critical {
500            let mut sound = serializer.serialize_struct("Sound", 3)?;
501            sound.serialize_field("critical", &1)?;
502            sound.serialize_field("name", &self.name)?;
503            sound.serialize_field("volume", &self.volume.clamp(0., 1.))?;
504            sound.end()
505        } else {
506            self.name.serialize(serializer)
507        }
508    }
509}
510
511/// Alert interruption level.
512#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
513#[serde(rename_all = "kebab-case")]
514pub enum InterruptionLevel {
515    /// The system presents the notification immediately, lights up the screen,
516    /// and can play a sound.
517    Active,
518
519    /// The system presents the notification immediately, lights up the screen,
520    /// and bypasses the mute switch to play a sound.
521    Critical,
522
523    /// The system adds the notification to the notification list without
524    /// lighting up the screen or playing a sound.
525    Passive,
526
527    /// The system presents the notification immediately, lights up the screen,
528    /// and can play a sound, but won’t break through system notification
529    /// controls.
530    TimeSensitive,
531}
532
533derive_fromstr_from_deserialize!(InterruptionLevel);
534derive_display_from_serialize!(InterruptionLevel);
535
536#[cfg(test)]
537mod test {
538    use std::str::FromStr;
539
540    use serde_json::json;
541
542    use super::*;
543
544    #[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
545    struct TestUserInfo {
546        foo: bool,
547        bar: i64,
548    }
549
550    #[test]
551    fn payload_de() {
552        assert_eq!(
553            serde_json::from_str::<Payload>(
554                &json!({
555                    "aps": {
556                        "alert": "Hello World!",
557                        "badge": 11,
558                        "sound": "default",
559                        "thread-id": "my-thread-id",
560                        "category": "my-category",
561                        "content-available": 1,
562                        "mutable-content": 1,
563                        "target-content-id": "my-target-id",
564                        "interruption-level": "active",
565                        "relevance-score": 0.5,
566                    },
567                })
568                .to_string()
569            )
570            .unwrap(),
571            Payload {
572                aps: Aps {
573                    alert: Some("Hello World!".into()),
574                    badge: Some(11),
575                    sound: Some("default".into()),
576                    thread_id: Some("my-thread-id".into()),
577                    category: Some("my-category".into()),
578                    content_available: true,
579                    mutable_content: true,
580                    target_content_id: Some("my-target-id".into()),
581                    interruption_level: Some(InterruptionLevel::Active),
582                    relevance_score: Some(0.5),
583                },
584                user_info: Some(())
585            }
586        );
587        assert_eq!(
588            serde_json::from_str::<Payload<TestUserInfo>>(
589                &json!({
590                    "aps": {
591                        "alert": "Hello World!",
592                    },
593                    "foo": true,
594                    "bar": -10,
595                })
596                .to_string()
597            )
598            .unwrap(),
599            Payload::<TestUserInfo> {
600                aps: Aps {
601                    alert: Some("Hello World!".into()),
602                    ..Default::default()
603                },
604                user_info: Some(TestUserInfo {
605                    foo: true,
606                    bar: -10
607                }),
608            }
609        );
610    }
611
612    #[test]
613    fn payload_ser() {
614        assert_eq!(
615            serde_json::to_value(&Payload {
616                aps: Aps {
617                    alert: Some("Hello World!".into()),
618                    badge: Some(11),
619                    sound: Some("default".into()),
620                    thread_id: Some("my-thread-id".into()),
621                    category: Some("my-category".into()),
622                    content_available: true,
623                    mutable_content: true,
624                    target_content_id: Some("my-target-id".into()),
625                    interruption_level: Some(InterruptionLevel::Active),
626                    relevance_score: Some(0.5),
627                },
628                user_info: Some(()),
629            })
630            .unwrap(),
631            json!({
632                "aps": {
633                    "alert": "Hello World!",
634                    "badge": 11,
635                    "sound": "default",
636                    "thread-id": "my-thread-id",
637                    "category": "my-category",
638                    "content-available": 1,
639                    "mutable-content": 1,
640                    "target-content-id": "my-target-id",
641                    "interruption-level": "active",
642                    "relevance-score": 0.5,
643                },
644            })
645        );
646        assert_eq!(
647            serde_json::to_value(&Payload::<TestUserInfo> {
648                aps: Aps {
649                    alert: Some("Hello World!".into()),
650                    ..Default::default()
651                },
652                user_info: Some(TestUserInfo {
653                    foo: true,
654                    bar: -10
655                }),
656            })
657            .unwrap(),
658            json!({
659                "aps": {
660                    "alert": "Hello World!",
661                },
662                "foo": true,
663                "bar": -10,
664            })
665        );
666    }
667
668    #[test]
669    fn alert_de() {
670        assert_eq!(
671            serde_json::from_str::<Alert>(&json!("Hello World!").to_string()).unwrap(),
672            Alert {
673                body: Some("Hello World!".into()),
674                ..Default::default()
675            }
676        );
677        assert_eq!(
678            serde_json::from_str::<Alert>(
679                &json!({
680                    "body": "Hello World!"
681                })
682                .to_string()
683            )
684            .unwrap(),
685            Alert {
686                body: Some("Hello World!".into()),
687                ..Default::default()
688            }
689        );
690        assert_eq!(
691            serde_json::from_str::<Alert>(
692                &json!({
693                    "title": "Title",
694                    "subtitle": "Subtitle",
695                    "body": "Hello World!",
696                    "launch-image": "http://example.com/img.png",
697                })
698                .to_string()
699            )
700            .unwrap(),
701            Alert {
702                title: Some("Title".into()),
703                subtitle: Some("Subtitle".into()),
704                body: Some("Hello World!".into()),
705                launch_image: Some("http://example.com/img.png".into()),
706                ..Default::default()
707            }
708        );
709        assert_eq!(
710            serde_json::from_str::<Alert>(
711                &json!({
712                    "title": "Title",
713                    "subtitle": "Subtitle",
714                    "body": "Hello World!",
715                    "launch-image": "http://example.com/img.png",
716                    "title-loc-key": "REQUEST_FORMAT",
717                    "title-loc-args": ["Foo", "Bar"],
718                    "subtitle-loc-key": "SUBTITLE_FORMAT",
719                    "subtitle-loc-args": ["Bar", "Baz"],
720                    "loc-key": "BODY_FORMAT",
721                    "loc-args": ["Apple", "Pie"],
722                })
723                .to_string()
724            )
725            .unwrap(),
726            Alert {
727                title: Some("Title".into()),
728                subtitle: Some("Subtitle".into()),
729                body: Some("Hello World!".into()),
730                launch_image: Some("http://example.com/img.png".into()),
731                title_loc_key: Some("REQUEST_FORMAT".into()),
732                title_loc_args: Some(vec!["Foo".into(), "Bar".into()]),
733                subtitle_loc_key: Some("SUBTITLE_FORMAT".into()),
734                subtitle_loc_args: Some(vec!["Bar".into(), "Baz".into()]),
735                loc_key: Some("BODY_FORMAT".into()),
736                loc_args: Some(vec!["Apple".into(), "Pie".into()]),
737            }
738        );
739    }
740
741    #[test]
742    fn alert_ser() {
743        assert_eq!(
744            serde_json::to_string(&Alert {
745                body: Some("Hello World!".into()),
746                ..Default::default()
747            })
748            .unwrap(),
749            json!("Hello World!").to_string()
750        );
751        assert_eq!(
752            serde_json::to_value(&Alert {
753                title: Some("Title".into()),
754                subtitle: Some("Subtitle".into()),
755                body: Some("Hello World!".into()),
756                launch_image: Some("http://example.com/img.png".into()),
757                ..Default::default()
758            })
759            .unwrap(),
760            json!({
761                "title": "Title",
762                "subtitle": "Subtitle",
763                "body": "Hello World!",
764                "launch-image": "http://example.com/img.png",
765            })
766        );
767        assert_eq!(
768            serde_json::to_value(&Alert {
769                title: Some("Title".into()),
770                subtitle: Some("Subtitle".into()),
771                body: Some("Hello World!".into()),
772                launch_image: Some("http://example.com/img.png".into()),
773                title_loc_key: Some("REQUEST_FORMAT".into()),
774                title_loc_args: Some(vec!["Foo".into(), "Bar".into()]),
775                subtitle_loc_key: Some("SUBTITLE_FORMAT".into()),
776                subtitle_loc_args: Some(vec!["Bar".into(), "Baz".into()]),
777                loc_key: Some("BODY_FORMAT".into()),
778                loc_args: Some(vec!["Apple".into(), "Pie".into()]),
779            })
780            .unwrap(),
781            json!({
782                "title-loc-key": "REQUEST_FORMAT",
783                "title-loc-args": ["Foo", "Bar"],
784                "subtitle-loc-key": "SUBTITLE_FORMAT",
785                "subtitle-loc-args": ["Bar", "Baz"],
786                "loc-key": "BODY_FORMAT",
787                "loc-args": ["Apple", "Pie"],
788                "launch-image": "http://example.com/img.png",
789            })
790        );
791    }
792
793    #[test]
794    fn sound_de() {
795        assert_eq!(
796            serde_json::from_str::<Sound>(&json!("default").to_string()).unwrap(),
797            Sound {
798                critical: false,
799                name: "default".into(),
800                volume: 0.
801            }
802        );
803        assert_eq!(
804            serde_json::from_str::<Sound>(
805                &json!({
806                    "critical": 1,
807                    "name": "custom",
808                    "volume": 0.5,
809                })
810                .to_string()
811            )
812            .unwrap(),
813            Sound {
814                critical: true,
815                name: "custom".into(),
816                volume: 0.5
817            }
818        );
819        assert_eq!(
820            serde_json::from_str::<Sound>(
821                &json!({
822                    "critical": 0,
823                    "name": "default",
824                    "volume": 1.,
825                })
826                .to_string()
827            )
828            .unwrap(),
829            Sound {
830                critical: false,
831                name: "default".into(),
832                volume: 1.
833            }
834        );
835        assert!(serde_json::from_str::<Sound>(
836            &json!({
837                "name": "default",
838                "volume": 1.,
839            })
840            .to_string()
841        )
842        .is_err());
843    }
844
845    #[test]
846    fn sound_ser() {
847        assert_eq!(
848            serde_json::to_string(&Sound {
849                critical: false,
850                name: "default".into(),
851                volume: 0.
852            })
853            .unwrap(),
854            json!("default").to_string(),
855        );
856        assert_eq!(
857            serde_json::to_string(&Sound {
858                critical: true,
859                name: "custom".into(),
860                volume: 0.5
861            })
862            .unwrap(),
863            json!({
864                "critical": 1,
865                "name": "custom",
866                "volume": 0.5,
867            })
868            .to_string()
869        );
870        assert_eq!(
871            serde_json::to_string(&Sound {
872                critical: true,
873                name: "default".into(),
874                volume: 1.
875            })
876            .unwrap(),
877            json!({
878                "critical": 1,
879                "name": "default",
880                "volume": 1.,
881            })
882            .to_string()
883        );
884        assert_eq!(
885            serde_json::to_string(&Sound {
886                critical: true,
887                name: "default".into(),
888                volume: 2.
889            })
890            .unwrap(),
891            json!({
892                "critical": 1,
893                "name": "default",
894                "volume": 1.,
895            })
896            .to_string()
897        );
898    }
899
900    #[test]
901    fn interruption_level_de() {
902        assert_eq!(
903            serde_json::from_str::<InterruptionLevel>("\"active\"").unwrap(),
904            InterruptionLevel::Active
905        );
906        assert_eq!(
907            serde_json::from_str::<InterruptionLevel>("\"critical\"").unwrap(),
908            InterruptionLevel::Critical
909        );
910        assert_eq!(
911            serde_json::from_str::<InterruptionLevel>("\"passive\"").unwrap(),
912            InterruptionLevel::Passive
913        );
914        assert_eq!(
915            serde_json::from_str::<InterruptionLevel>("\"time-sensitive\"").unwrap(),
916            InterruptionLevel::TimeSensitive
917        );
918        assert!(serde_json::from_str::<InterruptionLevel>("\"invalid\"").is_err());
919    }
920
921    #[test]
922    fn interruption_level_ser() {
923        assert_eq!(
924            serde_json::to_string(&InterruptionLevel::Active).unwrap(),
925            "\"active\""
926        );
927        assert_eq!(
928            serde_json::to_string(&InterruptionLevel::Critical).unwrap(),
929            "\"critical\""
930        );
931        assert_eq!(
932            serde_json::to_string(&InterruptionLevel::Passive).unwrap(),
933            "\"passive\""
934        );
935        assert_eq!(
936            serde_json::to_string(&InterruptionLevel::TimeSensitive).unwrap(),
937            "\"time-sensitive\""
938        );
939    }
940
941    #[test]
942    fn interruption_level_from_str() {
943        assert_eq!(
944            InterruptionLevel::from_str("active").unwrap(),
945            InterruptionLevel::Active
946        );
947        assert_eq!(
948            InterruptionLevel::from_str("critical").unwrap(),
949            InterruptionLevel::Critical
950        );
951        assert_eq!(
952            InterruptionLevel::from_str("passive").unwrap(),
953            InterruptionLevel::Passive
954        );
955        assert_eq!(
956            InterruptionLevel::from_str("time-sensitive").unwrap(),
957            InterruptionLevel::TimeSensitive
958        );
959        assert!(InterruptionLevel::from_str("invalid").is_err());
960    }
961
962    #[test]
963    fn interruption_level_to_str() {
964        assert_eq!(InterruptionLevel::Active.to_string(), "active");
965        assert_eq!(InterruptionLevel::Critical.to_string(), "critical");
966        assert_eq!(InterruptionLevel::Passive.to_string(), "passive");
967        assert_eq!(
968            InterruptionLevel::TimeSensitive.to_string(),
969            "time-sensitive"
970        );
971    }
972}