Skip to main content

bark_apns/
message.rs

1use crate::{
2    device::Device,
3    error::{Error, Result},
4};
5use serde::Serialize;
6
7const DEFAULT_TITLE: &str = "Notification";
8const CATEGORY: &str = "myNotificationCategory";
9const ENCRYPTED_TITLE: &str = "Bark";
10const ENCRYPTED_BODY: &str = "Encrypted Message";
11
12/// Bark interruption level for a notification.
13///
14/// These values match Bark's documented `level` parameter. The level is also
15/// serialized into APNs as `aps.interruption-level` for direct delivery.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
17pub enum InterruptionLevel {
18    /// Important alert. Bark uses this with critical notification sound handling.
19    #[serde(rename = "critical")]
20    Critical,
21    /// Time-sensitive notification, available on iOS 15 and later.
22    #[serde(rename = "timeSensitive")]
23    TimeSensitive,
24    /// Default active notification behavior.
25    #[serde(rename = "active")]
26    Active,
27    /// Adds the notification to Notification Center without actively alerting.
28    #[serde(rename = "passive")]
29    Passive,
30}
31
32impl InterruptionLevel {
33    /// Returns the `level` string recognized by Bark.
34    pub const fn as_bark_str(self) -> &'static str {
35        match self {
36            Self::Critical => "critical",
37            Self::TimeSensitive => "timeSensitive",
38            Self::Active => "active",
39            Self::Passive => "passive",
40        }
41    }
42}
43
44/// Builder for a Bark-compatible push message.
45///
46/// `Message` serializes to the JSON payload sent to APNs. For normal pushes it
47/// writes both APNs `aps` fields and Bark custom fields. For encrypted pushes it
48/// encrypts Bark request fields into `ciphertext` and keeps only the APNs
49/// placeholder alert plus `ciphertext`/`iv` in clear text.
50///
51/// Start with [`Message::new`] and chain the fields that should be present:
52///
53/// ```
54/// use bark_apns::{InterruptionLevel, Message};
55///
56/// let message = Message::new()
57///     .title("Build")
58///     .body("finished")
59///     .group("ci")
60///     .level(InterruptionLevel::TimeSensitive);
61/// ```
62#[derive(Clone, Debug)]
63pub struct Message {
64    /// Push title shown by iOS and stored by Bark.
65    title: String,
66    /// Optional push subtitle shown by iOS.
67    subtitle: Option<String>,
68    /// Plain-text fallback body.
69    body: String,
70    /// Optional Markdown body rendered by Bark before display.
71    markdown: Option<String>,
72    /// Optional image URL downloaded and attached by Bark.
73    image: Option<String>,
74    /// APNs and Bark interruption level.
75    level: InterruptionLevel,
76    /// Critical alert volume in Bark's 0-10 scale.
77    volume: Option<u8>,
78    /// App badge value.
79    badge: Option<u64>,
80    /// Bark automatic copy flag.
81    auto_copy: Option<bool>,
82    /// Explicit text copied by Bark when automatic copy runs.
83    copy: Option<String>,
84    /// Bark sound name, without requiring a `.caf` suffix from callers.
85    sound: Option<String>,
86    /// Bark call-style repeated ringing flag.
87    call: bool,
88    /// Optional custom notification icon URL.
89    icon: Option<String>,
90    /// Bark group name and APNs thread id for normal pushes.
91    group: Option<String>,
92    /// Bark archive preference override.
93    archive: Option<bool>,
94    /// Optional archive retention time in seconds.
95    ttl: Option<u64>,
96    /// URL opened when the notification is tapped.
97    url: Option<String>,
98    /// Bark action parameter, such as `alert`.
99    action: Option<String>,
100    /// Bark message id and APNs collapse id.
101    id: Option<String>,
102    /// Whether this message is a Bark delete request.
103    delete: bool,
104    /// Whether this message should be encrypted for devices that support it.
105    encrypted: bool,
106}
107
108impl Message {
109    /// Creates an empty message builder.
110    ///
111    /// The default title is `"Notification"`, the default level is
112    /// [`InterruptionLevel::Active`], and no sound is set. A normal notification
113    /// must eventually contain either [`Message::body`] or [`Message::markdown`].
114    /// Delete messages created with [`Message::delete`] do not require content.
115    pub fn new() -> Self {
116        Self {
117            title: DEFAULT_TITLE.to_owned(),
118            body: String::new(),
119            subtitle: None,
120            markdown: None,
121            image: None,
122            level: InterruptionLevel::Active,
123            volume: None,
124            badge: None,
125            auto_copy: None,
126            copy: None,
127            sound: None,
128            call: false,
129            icon: None,
130            group: None,
131            archive: None,
132            ttl: None,
133            url: None,
134            action: None,
135            id: None,
136            delete: false,
137            encrypted: false,
138        }
139    }
140
141    /// Sets the push title.
142    ///
143    /// If omitted, Bark receives `"Notification"` as the title.
144    pub fn title<T>(mut self, title: T) -> Self
145    where
146        T: Into<String>,
147    {
148        self.title = title.into();
149        self
150    }
151
152    /// Sets the push subtitle.
153    ///
154    /// Bark maps this to the notification subtitle when the value is present.
155    /// Empty or whitespace-only values are omitted.
156    pub fn subtitle<S>(mut self, subtitle: S) -> Self
157    where
158        S: Into<String>,
159    {
160        self.subtitle = non_empty(subtitle.into());
161        self
162    }
163
164    /// Sets the plain-text push body.
165    ///
166    /// Bark uses this as the notification body unless `markdown` is also
167    /// provided. For encrypted pushes, this value is included inside the
168    /// encrypted Bark JSON.
169    pub fn body<B>(mut self, body: B) -> Self
170    where
171        B: Into<String>,
172    {
173        self.body = body.into();
174        self
175    }
176
177    /// Sets the Markdown body.
178    ///
179    /// Bark's notification service extension renders this value and treats it as
180    /// taking precedence over `body` for display. Empty or whitespace-only values
181    /// are omitted.
182    ///
183    /// Bark parses Markdown with Apple's Swift Markdown package and its own
184    /// `MarkdownParser`. The parser handles paragraphs, headings, block quotes,
185    /// strong emphasis, emphasis, strikethrough, inline code, fenced or indented
186    /// code blocks, links, images, ordered lists, unordered lists, nested list
187    /// indentation, task-list checkboxes, soft breaks, and hard line breaks.
188    ///
189    /// For push notification display, Bark's notification service extension uses
190    /// the parsed attributed string's plain `.string` value as the notification
191    /// body and collapses repeated blank lines. That means visual styles such as
192    /// bold, italic, strikethrough, link color, code font, and quote color are
193    /// not preserved in the notification banner itself; the rendered text,
194    /// headings, list markers, checkbox symbols, code text, link text, and image
195    /// alt text remain.
196    pub fn markdown<M>(mut self, markdown: M) -> Self
197    where
198        M: Into<String>,
199    {
200        self.markdown = non_empty(markdown.into());
201        self
202    }
203
204    /// Sets a push image URL.
205    ///
206    /// Bark downloads this image in the notification service extension and
207    /// attaches it to the notification when possible. Empty or whitespace-only
208    /// values are omitted.
209    pub fn image<I>(mut self, image: I) -> Self
210    where
211        I: Into<String>,
212    {
213        self.image = non_empty(image.into());
214        self
215    }
216
217    /// Sets the notification interruption level.
218    ///
219    /// Defaults to [`InterruptionLevel::Active`]. Critical notifications may
220    /// require the Bark app and APNs topic to have the appropriate entitlement.
221    pub fn level(mut self, level: InterruptionLevel) -> Self {
222        self.level = level;
223        self
224    }
225
226    /// Sets the critical alert volume from `0` to `10`.
227    ///
228    /// Bark uses this value for `level=critical`. Values larger than `10` are
229    /// clamped to `10`.
230    pub fn volume(mut self, volume: u8) -> Self {
231        self.volume = Some(volume.min(10));
232        self
233    }
234
235    /// Sets the app badge number.
236    ///
237    /// The value is serialized into APNs `aps.badge` and Bark's `badge`
238    /// parameter.
239    pub fn badge(mut self, badge: u64) -> Self {
240        self.badge = Some(badge);
241        self
242    }
243
244    /// Sets Bark's automatic copy flag.
245    ///
246    /// Bark interprets `"1"` as enabled. On newer iOS versions the user may
247    /// still need to long-press or expand the notification to copy.
248    pub fn auto_copy(mut self, auto_copy: bool) -> Self {
249        self.auto_copy = Some(auto_copy);
250        self
251    }
252
253    /// Sets the text Bark should copy when automatic copy runs.
254    ///
255    /// If omitted, Bark copies the notification body. Empty or whitespace-only
256    /// values are omitted.
257    pub fn copy<C>(mut self, copy: C) -> Self
258    where
259        C: Into<String>,
260    {
261        self.copy = non_empty(copy.into());
262        self
263    }
264
265    /// Sets the Bark sound name.
266    ///
267    /// This crate does not set a default sound, matching Bark's request
268    /// semantics. For normal pushes, APNs receives `aps.sound`; if the value does
269    /// not end in `.caf`, the APNs sound name is suffixed with `.caf`. For
270    /// encrypted pushes, the sound is kept inside the encrypted Bark JSON and is
271    /// not exposed in clear text.
272    pub fn sound<S>(mut self, sound: S) -> Self
273    where
274        S: Into<String>,
275    {
276        self.sound = non_empty(sound.into());
277        self
278    }
279
280    /// Enables or disables Bark's repeated call-style ringing.
281    ///
282    /// Bark expects `"1"` to enable this behavior. Passing `false` omits the
283    /// field.
284    pub fn call(mut self, call: bool) -> Self {
285        self.call = call;
286        self
287    }
288
289    /// Sets a custom icon URL.
290    ///
291    /// Bark's notification service extension downloads and caches the icon, then
292    /// uses it to replace the default Bark icon when iOS supports the feature.
293    /// Empty or whitespace-only values are omitted.
294    pub fn icon<I>(mut self, icon: I) -> Self
295    where
296        I: Into<String>,
297    {
298        self.icon = non_empty(icon.into());
299        self
300    }
301
302    /// Sets the Bark group name.
303    ///
304    /// The group is used for Notification Center grouping and for Bark's history
305    /// grouping. Normal pushes also set APNs `aps.thread-id`.
306    pub fn group<G>(mut self, group: G) -> Self
307    where
308        G: Into<String>,
309    {
310        self.group = non_empty(group.into());
311        self
312    }
313
314    /// Sets Bark's archive flag.
315    ///
316    /// `true` serializes `"1"` and asks Bark to save the push to history.
317    /// `false` serializes `"0"` and asks Bark not to archive it. If omitted,
318    /// Bark uses the app's own archive setting.
319    pub fn archive(mut self, archive: bool) -> Self {
320        self.archive = Some(archive);
321        self
322    }
323
324    /// Sets the archive time-to-live in seconds.
325    ///
326    /// Bark applies this only to messages saved to history.
327    pub fn ttl(mut self, ttl: u64) -> Self {
328        self.ttl = Some(ttl);
329        self
330    }
331
332    /// Sets the URL opened when the notification is tapped.
333    ///
334    /// Bark supports URL schemes and universal links. Empty or whitespace-only
335    /// values are omitted.
336    pub fn url<U>(mut self, url: U) -> Self
337    where
338        U: Into<String>,
339    {
340        self.url = non_empty(url.into());
341        self
342    }
343
344    /// Sets Bark's `action` parameter.
345    ///
346    /// Bark documents `action=alert` as showing an action popup when the user
347    /// opens the app from the notification.
348    pub fn action<A>(mut self, action: A) -> Self
349    where
350        A: Into<String>,
351    {
352        self.action = non_empty(action.into());
353        self
354    }
355
356    /// Sets the Bark message id.
357    ///
358    /// The same id can update a delivered notification. It is also required for
359    /// delete messages. APNs collapse ids must be at most 64 bytes, so this crate
360    /// validates the id before sending.
361    pub fn id<I>(mut self, id: I) -> Self
362    where
363        I: Into<String>,
364    {
365        self.id = non_empty(id.into());
366        self
367    }
368
369    /// Marks this message as a Bark delete request.
370    ///
371    /// Delete requests serialize as a background APNs payload with `delete=1`
372    /// and require [`Message::id`]. They do not require body or markdown
373    /// content.
374    pub fn delete(mut self) -> Self {
375        self.delete = true;
376        self
377    }
378
379    /// Marks this message for encrypted delivery.
380    ///
381    /// Encryption settings are configured on each [`Device`], matching Bark's
382    /// device-level Push Encryption settings. When this flag is set, sending to
383    /// a device without encryption settings returns an error.
384    ///
385    /// [`Device`]: crate::Device
386    pub fn encrypt(mut self) -> Self {
387        self.encrypted = true;
388        self
389    }
390
391    /// Returns whether this message is marked for encrypted delivery.
392    pub fn is_encrypted(&self) -> bool {
393        self.encrypted
394    }
395
396    pub(crate) fn is_delete(&self) -> bool {
397        self.delete
398    }
399
400    pub(crate) fn id_value(&self) -> Option<&str> {
401        self.id.as_deref()
402    }
403
404    pub(crate) fn payload_bytes(&self, device: Option<&Device>) -> Result<Vec<u8>> {
405        Ok(serde_json::to_vec(&self.payload_value(device)?)?)
406    }
407
408    pub(crate) fn validate_headers(&self) -> Result<()> {
409        if let Some(id) = &self.id {
410            let actual = id.len();
411            if actual > 64 {
412                return Err(Error::InvalidCollapseId { actual });
413            }
414        }
415
416        if self.delete && self.id.is_none() {
417            return Err(Error::MissingMessageIdForDelete);
418        }
419
420        Ok(())
421    }
422
423    pub(crate) fn validate_payload(&self) -> Result<()> {
424        self.validate_headers()?;
425
426        if !self.delete {
427            self.validate_content()?;
428        }
429
430        Ok(())
431    }
432
433    fn payload_value(&self, device: Option<&Device>) -> Result<serde_json::Value> {
434        self.validate_payload()?;
435
436        if self.delete {
437            return Ok(serde_json::to_value(DeletePayload {
438                aps: DeleteAps {
439                    content_available: 1,
440                },
441                delete: "1",
442                id: self.id.as_deref().expect("validated delete id"),
443            })?);
444        }
445
446        if self.encrypted {
447            let device = device.expect("encrypted messages are validated before payloads");
448            let fields = BarkPlaintextFields::from_message(self);
449            let plaintext = serde_json::to_vec(&fields)?;
450            let encrypted = device.encrypt_bark_json(&plaintext)?;
451            return Ok(serde_json::to_value(EncryptedNotificationPayload {
452                aps: ApsPayload::encrypted(),
453                ciphertext: encrypted.ciphertext,
454                iv: encrypted.iv,
455            })?);
456        }
457
458        Ok(serde_json::to_value(NotificationPayload {
459            aps: ApsPayload::plain(self),
460            fields: BarkFields::from_message(self),
461        })?)
462    }
463
464    fn validate_content(&self) -> Result<()> {
465        if self.body.trim().is_empty() && self.markdown.is_none() {
466            return Err(Error::EmptyMessage);
467        }
468
469        Ok(())
470    }
471}
472
473impl Default for Message {
474    fn default() -> Self {
475        Self::new()
476    }
477}
478
479#[derive(Debug, Serialize)]
480struct NotificationPayload {
481    aps: ApsPayload,
482    #[serde(flatten)]
483    fields: BarkFields,
484}
485
486#[derive(Debug, Serialize)]
487struct EncryptedNotificationPayload {
488    aps: ApsPayload,
489    ciphertext: String,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    iv: Option<String>,
492}
493
494#[derive(Debug, Serialize)]
495struct DeletePayload<'a> {
496    aps: DeleteAps,
497    delete: &'static str,
498    id: &'a str,
499}
500
501#[derive(Debug, Serialize)]
502struct DeleteAps {
503    #[serde(rename = "content-available")]
504    content_available: u8,
505}
506
507#[derive(Debug, Serialize)]
508struct ApsPayload {
509    #[serde(rename = "mutable-content")]
510    mutable_content: u8,
511    category: &'static str,
512    #[serde(rename = "interruption-level")]
513    interruption_level: InterruptionLevel,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    badge: Option<u64>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    sound: Option<String>,
518    #[serde(rename = "thread-id", skip_serializing_if = "Option::is_none")]
519    thread_id: Option<String>,
520    alert: AlertPayload,
521}
522
523impl ApsPayload {
524    fn plain(message: &Message) -> Self {
525        Self {
526            mutable_content: 1,
527            category: CATEGORY,
528            interruption_level: message.level,
529            badge: message.badge,
530            sound: message.sound.as_deref().map(apns_sound_name),
531            thread_id: message.group.clone(),
532            alert: AlertPayload {
533                title: message.title.clone(),
534                subtitle: message.subtitle.clone(),
535                body: message.body.clone(),
536            },
537        }
538    }
539
540    fn encrypted() -> Self {
541        Self {
542            mutable_content: 1,
543            category: CATEGORY,
544            interruption_level: InterruptionLevel::Active,
545            badge: None,
546            sound: None,
547            thread_id: None,
548            alert: AlertPayload {
549                title: ENCRYPTED_TITLE.to_owned(),
550                subtitle: None,
551                body: ENCRYPTED_BODY.to_owned(),
552            },
553        }
554    }
555}
556
557#[derive(Debug, Serialize)]
558struct AlertPayload {
559    title: String,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    subtitle: Option<String>,
562    body: String,
563}
564
565#[derive(Debug, Default, Serialize)]
566struct BarkFields {
567    #[serde(skip_serializing_if = "Option::is_none")]
568    level: Option<&'static str>,
569    #[serde(skip_serializing_if = "Option::is_none")]
570    volume: Option<String>,
571    #[serde(skip_serializing_if = "Option::is_none")]
572    badge: Option<String>,
573    #[serde(skip_serializing_if = "Option::is_none")]
574    call: Option<&'static str>,
575    #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
576    auto_copy: Option<&'static str>,
577    #[serde(skip_serializing_if = "Option::is_none")]
578    copy: Option<String>,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    sound: Option<String>,
581    #[serde(skip_serializing_if = "Option::is_none")]
582    icon: Option<String>,
583    #[serde(skip_serializing_if = "Option::is_none")]
584    image: Option<String>,
585    #[serde(skip_serializing_if = "Option::is_none")]
586    group: Option<String>,
587    #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
588    archive: Option<&'static str>,
589    #[serde(skip_serializing_if = "Option::is_none")]
590    ttl: Option<String>,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    url: Option<String>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    action: Option<String>,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    markdown: Option<String>,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    id: Option<String>,
599}
600
601impl BarkFields {
602    fn from_message(message: &Message) -> Self {
603        Self {
604            level: Some(message.level.as_bark_str()),
605            volume: message.volume.map(|volume| volume.to_string()),
606            badge: message.badge.map(|badge| badge.to_string()),
607            call: message.call.then_some("1"),
608            auto_copy: message
609                .auto_copy
610                .map(|auto_copy| if auto_copy { "1" } else { "0" }),
611            copy: message.copy.clone(),
612            sound: message.sound.clone(),
613            icon: message.icon.clone(),
614            image: message.image.clone(),
615            group: message.group.clone(),
616            archive: message
617                .archive
618                .map(|archive| if archive { "1" } else { "0" }),
619            ttl: message.ttl.map(|ttl| ttl.to_string()),
620            url: message.url.clone(),
621            action: message.action.clone(),
622            markdown: message.markdown.clone(),
623            id: message.id.clone(),
624        }
625    }
626}
627
628#[derive(Debug, Default, Serialize)]
629struct BarkPlaintextFields {
630    title: String,
631    #[serde(skip_serializing_if = "Option::is_none")]
632    subtitle: Option<String>,
633    body: String,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    markdown: Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    level: Option<&'static str>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    volume: Option<String>,
640    #[serde(skip_serializing_if = "Option::is_none")]
641    badge: Option<String>,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    call: Option<&'static str>,
644    #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
645    auto_copy: Option<&'static str>,
646    #[serde(skip_serializing_if = "Option::is_none")]
647    copy: Option<String>,
648    #[serde(skip_serializing_if = "Option::is_none")]
649    sound: Option<String>,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    icon: Option<String>,
652    #[serde(skip_serializing_if = "Option::is_none")]
653    image: Option<String>,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    group: Option<String>,
656    #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
657    archive: Option<&'static str>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    ttl: Option<String>,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    url: Option<String>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    action: Option<String>,
664    #[serde(skip_serializing_if = "Option::is_none")]
665    id: Option<String>,
666}
667
668impl BarkPlaintextFields {
669    fn from_message(message: &Message) -> Self {
670        let fields = BarkFields::from_message(message);
671        Self {
672            title: message.title.clone(),
673            subtitle: message.subtitle.clone(),
674            body: message.body.clone(),
675            markdown: fields.markdown,
676            level: fields.level,
677            volume: fields.volume,
678            badge: fields.badge,
679            call: fields.call,
680            auto_copy: fields.auto_copy,
681            copy: fields.copy,
682            sound: fields.sound,
683            icon: fields.icon,
684            image: fields.image,
685            group: fields.group,
686            archive: fields.archive,
687            ttl: fields.ttl,
688            url: fields.url,
689            action: fields.action,
690            id: fields.id,
691        }
692    }
693}
694
695fn non_empty(value: String) -> Option<String> {
696    if value.trim().is_empty() {
697        None
698    } else {
699        Some(value)
700    }
701}
702
703fn apns_sound_name(sound: &str) -> String {
704    if sound.ends_with(".caf") {
705        sound.to_owned()
706    } else {
707        format!("{sound}.caf")
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use serde_json::json;
714
715    use super::*;
716    use crate::device::{EncryptionAlgorithm, EncryptionMode};
717
718    fn payload(message: Message) -> serde_json::Value {
719        message.payload_value(None).unwrap()
720    }
721
722    fn encrypted_payload(message: Message, device: &Device) -> serde_json::Value {
723        message.payload_value(Some(device)).unwrap()
724    }
725
726    #[test]
727    fn serializes_markdown_without_manual_escaping() {
728        let payload = payload(
729            Message::new()
730                .title("build")
731                .body("fallback")
732                .markdown("## ok\nquoted: \"yes\"")
733                .group(String::from("ci")),
734        );
735
736        assert_eq!(payload["markdown"], "## ok\nquoted: \"yes\"");
737        assert_eq!(payload["group"], "ci");
738        assert_eq!(payload["aps"]["alert"]["body"], "fallback");
739    }
740
741    #[test]
742    fn sound_is_only_serialized_when_explicitly_set() {
743        let default_payload = payload(Message::new().body("quiet"));
744        let sound_payload = payload(Message::new().body("loud").sound("birdsong"));
745
746        assert!(default_payload["aps"].get("sound").is_none());
747        assert_eq!(sound_payload["aps"]["sound"], "birdsong.caf");
748        assert_eq!(sound_payload["sound"], "birdsong");
749    }
750
751    #[test]
752    fn serializes_delete_as_background_payload() {
753        let payload = payload(Message::new().id("deploy-42").delete());
754
755        assert_eq!(
756            payload,
757            json!({
758                "aps": { "content-available": 1 },
759                "delete": "1",
760                "id": "deploy-42"
761            })
762        );
763    }
764
765    #[test]
766    fn encrypted_payload_keeps_bark_fields_out_of_apns_user_info() {
767        let device = Device::new("aabb")
768            .encrypt(
769                EncryptionAlgorithm::AES128,
770                EncryptionMode::CBC,
771                "1234567890123456",
772            )
773            .unwrap();
774
775        let payload = encrypted_payload(
776            Message::new()
777                .title("secret title")
778                .body("secret body")
779                .markdown("**secret**")
780                .badge(7)
781                .group("ops")
782                .sound("birdsong")
783                .encrypt(),
784            &device,
785        );
786
787        assert_eq!(payload["aps"]["alert"]["title"], ENCRYPTED_TITLE);
788        assert_eq!(payload["aps"]["alert"]["body"], ENCRYPTED_BODY);
789        assert!(payload["aps"].get("sound").is_none());
790        assert_eq!(payload["iv"].as_str().unwrap().len(), 16);
791        assert!(payload.get("ciphertext").is_some());
792        assert!(payload.get("group").is_none());
793        assert!(payload.get("markdown").is_none());
794        assert!(payload.get("badge").is_none());
795    }
796
797    #[test]
798    fn delete_requires_id() {
799        let err = Message::new().delete().payload_bytes(None).unwrap_err();
800
801        assert!(matches!(err, Error::MissingMessageIdForDelete));
802    }
803
804    #[test]
805    fn normal_message_requires_body_or_markdown() {
806        let err = Message::new()
807            .title("empty")
808            .payload_bytes(None)
809            .unwrap_err();
810
811        assert!(matches!(err, Error::EmptyMessage));
812    }
813
814    #[test]
815    fn encrypt_marks_message_for_encrypted_delivery() {
816        let message = Message::new().body("secret").encrypt();
817
818        assert!(message.is_encrypted());
819    }
820}