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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
17pub enum InterruptionLevel {
18 #[serde(rename = "critical")]
20 Critical,
21 #[serde(rename = "timeSensitive")]
23 TimeSensitive,
24 #[serde(rename = "active")]
26 Active,
27 #[serde(rename = "passive")]
29 Passive,
30}
31
32impl InterruptionLevel {
33 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#[derive(Clone, Debug)]
63pub struct Message {
64 title: String,
66 subtitle: Option<String>,
68 body: String,
70 markdown: Option<String>,
72 image: Option<String>,
74 level: InterruptionLevel,
76 volume: Option<u8>,
78 badge: Option<u64>,
80 auto_copy: Option<bool>,
82 copy: Option<String>,
84 sound: Option<String>,
86 call: bool,
88 icon: Option<String>,
90 group: Option<String>,
92 archive: Option<bool>,
94 ttl: Option<u64>,
96 url: Option<String>,
98 action: Option<String>,
100 id: Option<String>,
102 delete: bool,
104 encrypted: bool,
106}
107
108impl Message {
109 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 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 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 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 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 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 pub fn level(mut self, level: InterruptionLevel) -> Self {
222 self.level = level;
223 self
224 }
225
226 pub fn volume(mut self, volume: u8) -> Self {
231 self.volume = Some(volume.min(10));
232 self
233 }
234
235 pub fn badge(mut self, badge: u64) -> Self {
240 self.badge = Some(badge);
241 self
242 }
243
244 pub fn auto_copy(mut self, auto_copy: bool) -> Self {
249 self.auto_copy = Some(auto_copy);
250 self
251 }
252
253 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 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 pub fn call(mut self, call: bool) -> Self {
285 self.call = call;
286 self
287 }
288
289 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 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 pub fn archive(mut self, archive: bool) -> Self {
320 self.archive = Some(archive);
321 self
322 }
323
324 pub fn ttl(mut self, ttl: u64) -> Self {
328 self.ttl = Some(ttl);
329 self
330 }
331
332 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 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 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 pub fn delete(mut self) -> Self {
375 self.delete = true;
376 self
377 }
378
379 pub fn encrypt(mut self) -> Self {
387 self.encrypted = true;
388 self
389 }
390
391 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}