use crate::{
crypto::Encryption,
error::{Error, Result},
};
use serde::Serialize;
const DEFAULT_TITLE: &str = "Notification";
const CATEGORY: &str = "myNotificationCategory";
const ENCRYPTED_TITLE: &str = "Bark";
const ENCRYPTED_BODY: &str = "Encrypted Message";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub enum InterruptionLevel {
#[serde(rename = "critical")]
Critical,
#[serde(rename = "timeSensitive")]
TimeSensitive,
#[serde(rename = "active")]
Active,
#[serde(rename = "passive")]
Passive,
}
impl InterruptionLevel {
pub const fn as_bark_str(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::TimeSensitive => "timeSensitive",
Self::Active => "active",
Self::Passive => "passive",
}
}
}
#[derive(Clone, Debug)]
pub struct Message {
title: String,
subtitle: Option<String>,
body: String,
markdown: Option<String>,
image: Option<String>,
level: InterruptionLevel,
volume: Option<u8>,
badge: Option<u64>,
auto_copy: Option<bool>,
copy: Option<String>,
sound: Option<String>,
call: bool,
icon: Option<String>,
group: Option<String>,
archive: Option<bool>,
ttl: Option<u64>,
url: Option<String>,
action: Option<String>,
id: Option<String>,
delete: bool,
encryption: Option<Encryption>,
}
impl Message {
pub fn new() -> Self {
Self {
title: DEFAULT_TITLE.to_owned(),
body: String::new(),
subtitle: None,
markdown: None,
image: None,
level: InterruptionLevel::Active,
volume: None,
badge: None,
auto_copy: None,
copy: None,
sound: None,
call: false,
icon: None,
group: None,
archive: None,
ttl: None,
url: None,
action: None,
id: None,
delete: false,
encryption: None,
}
}
pub fn title<T>(mut self, title: T) -> Self
where
T: Into<String>,
{
self.title = title.into();
self
}
pub fn subtitle<S>(mut self, subtitle: S) -> Self
where
S: Into<String>,
{
self.subtitle = non_empty(subtitle.into());
self
}
pub fn body<B>(mut self, body: B) -> Self
where
B: Into<String>,
{
self.body = body.into();
self
}
pub fn markdown<M>(mut self, markdown: M) -> Self
where
M: Into<String>,
{
self.markdown = non_empty(markdown.into());
self
}
pub fn image<I>(mut self, image: I) -> Self
where
I: Into<String>,
{
self.image = non_empty(image.into());
self
}
pub fn level(mut self, level: InterruptionLevel) -> Self {
self.level = level;
self
}
pub fn volume(mut self, volume: u8) -> Self {
self.volume = Some(volume.min(10));
self
}
pub fn badge(mut self, badge: u64) -> Self {
self.badge = Some(badge);
self
}
pub fn auto_copy(mut self, auto_copy: bool) -> Self {
self.auto_copy = Some(auto_copy);
self
}
pub fn copy<C>(mut self, copy: C) -> Self
where
C: Into<String>,
{
self.copy = non_empty(copy.into());
self
}
pub fn sound<S>(mut self, sound: S) -> Self
where
S: Into<String>,
{
self.sound = non_empty(sound.into());
self
}
pub fn call(mut self, call: bool) -> Self {
self.call = call;
self
}
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<String>,
{
self.icon = non_empty(icon.into());
self
}
pub fn group<G>(mut self, group: G) -> Self
where
G: Into<String>,
{
self.group = non_empty(group.into());
self
}
pub fn archive(mut self, archive: bool) -> Self {
self.archive = Some(archive);
self
}
pub fn ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
pub fn url<U>(mut self, url: U) -> Self
where
U: Into<String>,
{
self.url = non_empty(url.into());
self
}
pub fn action<A>(mut self, action: A) -> Self
where
A: Into<String>,
{
self.action = non_empty(action.into());
self
}
pub fn id<I>(mut self, id: I) -> Self
where
I: Into<String>,
{
self.id = non_empty(id.into());
self
}
pub fn delete(mut self) -> Self {
self.delete = true;
self
}
pub fn encryption(mut self, encryption: Encryption) -> Self {
self.encryption = Some(encryption);
self
}
pub(crate) fn is_delete(&self) -> bool {
self.delete
}
pub(crate) fn id_value(&self) -> Option<&str> {
self.id.as_deref()
}
pub(crate) fn payload_bytes(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(&self.payload_value()?)?)
}
pub(crate) fn validate_headers(&self) -> Result<()> {
if let Some(id) = &self.id {
let actual = id.len();
if actual > 64 {
return Err(Error::InvalidCollapseId { actual });
}
}
if self.delete && self.id.is_none() {
return Err(Error::MissingMessageIdForDelete);
}
Ok(())
}
fn payload_value(&self) -> Result<serde_json::Value> {
self.validate_headers()?;
if self.delete {
return Ok(serde_json::to_value(DeletePayload {
aps: DeleteAps {
content_available: 1,
},
delete: "1",
id: self.id.as_deref().expect("validated delete id"),
})?);
}
self.validate_content()?;
if let Some(encryption) = &self.encryption {
let fields = BarkPlaintextFields::from_message(self);
let plaintext = serde_json::to_vec(&fields)?;
let ciphertext = encryption.encrypt_bark_json(&plaintext)?;
return Ok(serde_json::to_value(EncryptedNotificationPayload {
aps: ApsPayload::encrypted(),
ciphertext,
iv: encryption.apns_iv(),
})?);
}
Ok(serde_json::to_value(NotificationPayload {
aps: ApsPayload::plain(self),
fields: BarkFields::from_message(self),
})?)
}
fn validate_content(&self) -> Result<()> {
if self.body.trim().is_empty() && self.markdown.is_none() {
return Err(Error::EmptyMessage);
}
Ok(())
}
}
impl Default for Message {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize)]
struct NotificationPayload {
aps: ApsPayload,
#[serde(flatten)]
fields: BarkFields,
}
#[derive(Debug, Serialize)]
struct EncryptedNotificationPayload<'a> {
aps: ApsPayload,
ciphertext: String,
#[serde(skip_serializing_if = "Option::is_none")]
iv: Option<&'a str>,
}
#[derive(Debug, Serialize)]
struct DeletePayload<'a> {
aps: DeleteAps,
delete: &'static str,
id: &'a str,
}
#[derive(Debug, Serialize)]
struct DeleteAps {
#[serde(rename = "content-available")]
content_available: u8,
}
#[derive(Debug, Serialize)]
struct ApsPayload {
#[serde(rename = "mutable-content")]
mutable_content: u8,
category: &'static str,
#[serde(rename = "interruption-level")]
interruption_level: InterruptionLevel,
#[serde(skip_serializing_if = "Option::is_none")]
badge: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
sound: Option<String>,
#[serde(rename = "thread-id", skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
alert: AlertPayload,
}
impl ApsPayload {
fn plain(message: &Message) -> Self {
Self {
mutable_content: 1,
category: CATEGORY,
interruption_level: message.level,
badge: message.badge,
sound: message.sound.as_deref().map(apns_sound_name),
thread_id: message.group.clone(),
alert: AlertPayload {
title: message.title.clone(),
subtitle: message.subtitle.clone(),
body: message.body.clone(),
},
}
}
fn encrypted() -> Self {
Self {
mutable_content: 1,
category: CATEGORY,
interruption_level: InterruptionLevel::Active,
badge: None,
sound: None,
thread_id: None,
alert: AlertPayload {
title: ENCRYPTED_TITLE.to_owned(),
subtitle: None,
body: ENCRYPTED_BODY.to_owned(),
},
}
}
}
#[derive(Debug, Serialize)]
struct AlertPayload {
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle: Option<String>,
body: String,
}
#[derive(Debug, Default, Serialize)]
struct BarkFields {
#[serde(skip_serializing_if = "Option::is_none")]
level: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
volume: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
badge: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
call: Option<&'static str>,
#[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
auto_copy: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
copy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sound: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
archive: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
markdown: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
}
impl BarkFields {
fn from_message(message: &Message) -> Self {
Self {
level: Some(message.level.as_bark_str()),
volume: message.volume.map(|volume| volume.to_string()),
badge: message.badge.map(|badge| badge.to_string()),
call: message.call.then_some("1"),
auto_copy: message
.auto_copy
.map(|auto_copy| if auto_copy { "1" } else { "0" }),
copy: message.copy.clone(),
sound: message.sound.clone(),
icon: message.icon.clone(),
image: message.image.clone(),
group: message.group.clone(),
archive: message
.archive
.map(|archive| if archive { "1" } else { "0" }),
ttl: message.ttl.map(|ttl| ttl.to_string()),
url: message.url.clone(),
action: message.action.clone(),
markdown: message.markdown.clone(),
id: message.id.clone(),
}
}
}
#[derive(Debug, Default, Serialize)]
struct BarkPlaintextFields {
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle: Option<String>,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
markdown: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
level: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
volume: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
badge: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
call: Option<&'static str>,
#[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
auto_copy: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
copy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sound: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
archive: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
}
impl BarkPlaintextFields {
fn from_message(message: &Message) -> Self {
let fields = BarkFields::from_message(message);
Self {
title: message.title.clone(),
subtitle: message.subtitle.clone(),
body: message.body.clone(),
markdown: fields.markdown,
level: fields.level,
volume: fields.volume,
badge: fields.badge,
call: fields.call,
auto_copy: fields.auto_copy,
copy: fields.copy,
sound: fields.sound,
icon: fields.icon,
image: fields.image,
group: fields.group,
archive: fields.archive,
ttl: fields.ttl,
url: fields.url,
action: fields.action,
id: fields.id,
}
}
}
fn non_empty(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}
fn apns_sound_name(sound: &str) -> String {
if sound.ends_with(".caf") {
sound.to_owned()
} else {
format!("{sound}.caf")
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::crypto::{EncryptionAlgorithm, EncryptionMode};
fn payload(message: Message) -> serde_json::Value {
message.payload_value().unwrap()
}
#[test]
fn serializes_markdown_without_manual_escaping() {
let payload = payload(
Message::new()
.title("build")
.body("fallback")
.markdown("## ok\nquoted: \"yes\"")
.group(String::from("ci")),
);
assert_eq!(payload["markdown"], "## ok\nquoted: \"yes\"");
assert_eq!(payload["group"], "ci");
assert_eq!(payload["aps"]["alert"]["body"], "fallback");
}
#[test]
fn sound_is_only_serialized_when_explicitly_set() {
let default_payload = payload(Message::new().body("quiet"));
let sound_payload = payload(Message::new().body("loud").sound("birdsong"));
assert!(default_payload["aps"].get("sound").is_none());
assert_eq!(sound_payload["aps"]["sound"], "birdsong.caf");
assert_eq!(sound_payload["sound"], "birdsong");
}
#[test]
fn serializes_delete_as_background_payload() {
let payload = payload(Message::new().id("deploy-42").delete());
assert_eq!(
payload,
json!({
"aps": { "content-available": 1 },
"delete": "1",
"id": "deploy-42"
})
);
}
#[test]
fn encrypted_payload_keeps_bark_fields_out_of_apns_user_info() {
let encryption = Encryption::with_iv(
EncryptionAlgorithm::AES128,
EncryptionMode::CBC,
"1234567890123456",
"1111111111111111",
)
.unwrap();
let payload = payload(
Message::new()
.title("secret title")
.body("secret body")
.markdown("**secret**")
.badge(7)
.group("ops")
.sound("birdsong")
.encryption(encryption),
);
assert_eq!(payload["aps"]["alert"]["title"], ENCRYPTED_TITLE);
assert_eq!(payload["aps"]["alert"]["body"], ENCRYPTED_BODY);
assert!(payload["aps"].get("sound").is_none());
assert_eq!(payload["iv"], "1111111111111111");
assert!(payload.get("ciphertext").is_some());
assert!(payload.get("group").is_none());
assert!(payload.get("markdown").is_none());
assert!(payload.get("badge").is_none());
}
#[test]
fn delete_requires_id() {
let err = Message::new().delete().payload_bytes().unwrap_err();
assert!(matches!(err, Error::MissingMessageIdForDelete));
}
#[test]
fn normal_message_requires_body_or_markdown() {
let err = Message::new().title("empty").payload_bytes().unwrap_err();
assert!(matches!(err, Error::EmptyMessage));
}
}