use std::sync::{Mutex, OnceLock};
use objc2::rc::Retained;
use objc2_foundation::{NSArray, NSSet, NSString};
use objc2_user_notifications::{
UNNotificationAction, UNNotificationActionOptions, UNNotificationCategory,
UNNotificationCategoryOptions, UNTextInputNotificationAction, UNUserNotificationCenter,
};
use crate::worker;
static CATEGORIES: OnceLock<Mutex<Vec<ActionCategory>>> = OnceLock::new();
fn categories() -> &'static Mutex<Vec<ActionCategory>> {
CATEGORIES.get_or_init(|| Mutex::new(Vec::new()))
}
#[derive(Debug, Clone)]
pub enum Action {
Button {
identifier: String,
title: String,
requires_authentication: bool,
},
Reply {
identifier: String,
title: String,
button_title: String,
placeholder: String,
requires_authentication: bool,
},
}
impl Action {
pub fn button(identifier: impl Into<String>, title: impl Into<String>) -> Self {
Self::Button {
identifier: identifier.into(),
title: title.into(),
requires_authentication: false,
}
}
pub fn reply(
identifier: impl Into<String>,
title: impl Into<String>,
button_title: impl Into<String>,
placeholder: impl Into<String>,
) -> Self {
Self::Reply {
identifier: identifier.into(),
title: title.into(),
button_title: button_title.into(),
placeholder: placeholder.into(),
requires_authentication: false,
}
}
pub fn requires_authentication(self) -> Self {
match self {
Self::Button {
identifier, title, ..
} => Self::Button {
identifier,
title,
requires_authentication: true,
},
Self::Reply {
identifier,
title,
button_title,
placeholder,
..
} => Self::Reply {
identifier,
title,
button_title,
placeholder,
requires_authentication: true,
},
}
}
pub(crate) fn identifier(&self) -> &str {
match self {
Self::Button { identifier, .. } | Self::Reply { identifier, .. } => identifier,
}
}
pub(crate) fn build(&self) -> Retained<UNNotificationAction> {
match self {
Self::Button {
identifier,
title,
requires_authentication,
} => {
let mut options = UNNotificationActionOptions::empty();
if *requires_authentication {
options |= UNNotificationActionOptions::AuthenticationRequired;
}
UNNotificationAction::actionWithIdentifier_title_options(
&NSString::from_str(identifier),
&NSString::from_str(title),
options,
)
}
Self::Reply {
identifier,
title,
button_title,
placeholder,
requires_authentication,
} => {
let mut options = UNNotificationActionOptions::empty();
if *requires_authentication {
options |= UNNotificationActionOptions::AuthenticationRequired;
}
UNTextInputNotificationAction::actionWithIdentifier_title_options_textInputButtonTitle_textInputPlaceholder(
&NSString::from_str(identifier),
&NSString::from_str(title),
options,
&NSString::from_str(button_title),
&NSString::from_str(placeholder),
)
.into_super()
}
}
}
}
#[derive(Debug, Clone)]
pub struct ActionCategory {
pub identifier: String,
pub(crate) actions: Vec<Action>,
}
fn upsert_category(categories: &mut Vec<ActionCategory>, category: ActionCategory) {
match categories
.iter()
.position(|cat| cat.identifier == category.identifier)
{
Some(idx) => categories[idx] = category,
None => categories.push(category),
}
}
impl ActionCategory {
pub fn new(identifier: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
actions: Vec::new(),
}
}
pub(crate) fn from_actions(identifier: &str, actions: Vec<Action>) -> Self {
Self {
identifier: identifier.to_owned(),
actions,
}
}
pub fn action(mut self, action: Action) -> Self {
self.actions.push(action);
self
}
fn build_category(&self) -> Retained<UNNotificationCategory> {
let actions: Vec<_> = self.actions.iter().map(|act| act.build()).collect();
let actions_array = NSArray::from_retained_slice(&actions);
UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
&NSString::from_str(&self.identifier),
&actions_array,
&NSArray::new(),
UNNotificationCategoryOptions::CustomDismissAction,
)
}
pub(crate) fn register_now(&self) {
let mut categories = categories().lock().expect("category registry poisoned");
upsert_category(&mut categories, self.clone());
let built: Vec<_> = categories.iter().map(|cat| cat.build_category()).collect();
let full_set = NSSet::from_retained_slice(&built);
UNUserNotificationCenter::currentNotificationCenter().setNotificationCategories(&full_set);
log::debug!(
"registered category '{}' ({} total)",
self.identifier,
categories.len()
);
}
pub fn register(self) {
worker::dispatch(move || self.register_now());
}
}
#[cfg(test)]
mod test_upsert {
use super::{Action, ActionCategory, upsert_category};
fn cat(id: &str) -> ActionCategory {
ActionCategory::new(id)
}
fn cat_with_action(id: &str, action_id: &str) -> ActionCategory {
ActionCategory::new(id).action(Action::button(action_id, action_id))
}
#[test]
fn appends_when_identifier_is_new() {
let mut cats = vec![cat("a"), cat("b")];
upsert_category(&mut cats, cat("c"));
assert_eq!(cats.len(), 3);
assert_eq!(cats[2].identifier, "c");
}
#[test]
fn replaces_without_changing_length() {
let mut cats = vec![cat("a"), cat_with_action("b", "old"), cat("c")];
upsert_category(&mut cats, cat_with_action("b", "new"));
assert_eq!(cats.len(), 3, "length must not change on replace");
assert_eq!(cats[1].actions[0].identifier(), "new");
}
#[test]
fn replaces_at_correct_position_leaving_others_undisturbed() {
let mut cats = vec![cat("a"), cat("b"), cat("c")];
upsert_category(&mut cats, cat_with_action("a", "x"));
assert_eq!(cats[0].actions[0].identifier(), "x");
assert_eq!(cats[1].identifier, "b");
assert_eq!(cats[2].identifier, "c");
}
#[test]
fn first_insert_into_empty_vec() {
let mut cats = vec![];
upsert_category(&mut cats, cat("a"));
assert_eq!(cats.len(), 1);
assert_eq!(cats[0].identifier, "a");
}
}
#[cfg(test)]
mod test_action {
use super::Action;
#[test]
fn button_requires_authentication_sets_flag() {
let action = Action::button("id", "Confirm").requires_authentication();
assert!(matches!(
action,
Action::Button {
requires_authentication: true,
..
}
));
}
#[test]
fn reply_requires_authentication_sets_flag() {
let action = Action::reply("id", "Reply", "Send", "hint").requires_authentication();
assert!(matches!(
action,
Action::Reply {
requires_authentication: true,
..
}
));
}
#[test]
fn identifier_accessible_on_both_variants() {
assert_eq!(Action::button("btn-id", "OK").identifier(), "btn-id");
assert_eq!(
Action::reply("reply-id", "Reply", "Send", "hint").identifier(),
"reply-id"
);
}
}