Skip to main content

inset_embedder/
notifications.rs

1//! Notifications. Not a dart:ui file: Flutter reaches them through plugins such as
2//! `flutter_local_notifications`.
3//!
4//! A notification is a value posted under an id, and posting the same id again replaces it, as
5//! every system's own model is (`UNNotificationRequest`'s identifier, Android's `notify(id, …)`,
6//! the web's `tag`, Linux's `replaces_id`, a Windows toast's `Tag`); not an object the app keeps,
7//! which a click after a restart could not reach. A click, at launch or while the app runs, reaches
8//! the app by the notification's id and the action chosen, through
9//! `EmbedderClient::notification_activated`.
10//!
11//! A typed reply ([`NotificationReplies`]) and delivery at a later time
12//! ([`NotificationScheduling`]) are capabilities, `None` where the system lacks them, as the
13//! user's permission is ([`NotificationPermission`]) where the system keeps none for the app. A
14//! host answers each from the system's own, and never stands in for one the system lacks.
15//!
16//! Only [`NotificationPermission::request`] asks the user, with the system's own question. A post
17//! or a schedule never does, as no system's own post does (`UNUserNotificationCenter`'s `add`
18//! fails with `notificationsNotAllowed`, and Android's and the web's show nothing): without the
19//! permission it answers [`NotificationError::Denied`].
20
21use std::fmt;
22use std::future::Future;
23use std::pin::Pin;
24use std::time::SystemTime;
25
26use crate::IconImage;
27
28/// The system's notifications.
29pub trait Notifications {
30    /// Shows `notification`, in place of the one posted under its id while that is still there.
31    /// It never asks the user: where the system does not let the app notify, because the user was
32    /// never asked or said no, it answers [`NotificationError::Denied`], as the systems' own posts
33    /// do, and the app asks first through [`Notifications::permission`].
34    fn post(&self, notification: &Notification) -> PostFuture;
35
36    /// Takes away the notification posted or scheduled under `id`.
37    fn remove(&self, id: &str);
38
39    /// The user's permission, where the system keeps one for the app; `None` where any app may
40    /// notify.
41    fn permission(&self) -> Option<&dyn NotificationPermission>;
42
43    /// A typed reply, where the system takes one.
44    fn replies(&self) -> Option<&dyn NotificationReplies>;
45
46    /// Delivery at a later time, held by the system whether or not the app runs.
47    fn scheduling(&self) -> Option<&dyn NotificationScheduling>;
48}
49
50/// A notification: what the system shows under its id.
51#[derive(Clone, Debug, PartialEq)]
52pub struct Notification {
53    /// The app's own name for it, which a later post replaces and a click names.
54    pub id: String,
55    pub title: String,
56    pub body: Option<String>,
57    pub image: Option<IconImage>,
58    /// Buttons, and reply fields from [`NotificationReplies::reply_action`], in order.
59    pub actions: Vec<NotificationAction>,
60    /// Shown without the system's sound.
61    pub silent: bool,
62}
63
64impl Notification {
65    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Notification {
66        Notification {
67            id: id.into(),
68            title: title.into(),
69            body: None,
70            image: None,
71            actions: Vec::new(),
72            silent: false,
73        }
74    }
75}
76
77/// A button on a notification, or a field to reply in.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct NotificationAction {
80    /// The app's own name for it, which a click names.
81    pub id: String,
82    pub label: String,
83    /// The reply field's placeholder, for an action the host made a reply.
84    reply: Option<String>,
85}
86
87impl NotificationAction {
88    pub fn button(id: impl Into<String>, label: impl Into<String>) -> NotificationAction {
89        NotificationAction {
90            id: id.into(),
91            label: label.into(),
92            reply: None,
93        }
94    }
95
96    /// An action that asks for a typed reply, made only by [`NotificationReplies::reply_action`],
97    /// so an app has one only where the system takes replies.
98    pub(crate) fn with_reply(
99        id: impl Into<String>,
100        label: impl Into<String>,
101        placeholder: impl Into<String>,
102    ) -> NotificationAction {
103        NotificationAction {
104            id: id.into(),
105            label: label.into(),
106            reply: Some(placeholder.into()),
107        }
108    }
109
110    /// The placeholder of the reply field the action asks for; `None` for a button.
111    pub fn reply_placeholder(&self) -> Option<&str> {
112        self.reply.as_deref()
113    }
114}
115
116/// The user clicked a notification, or one of its actions, or replied.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct NotificationActivation {
119    /// The notification's id.
120    pub id: String,
121    /// The action's id; `None` for the notification itself.
122    pub action: Option<String>,
123    /// What the user typed, for a reply action.
124    pub reply: Option<String>,
125}
126
127/// What a post answers, once the system has the notification.
128pub type PostFuture = Pin<Box<dyn Future<Output = Result<(), NotificationError>>>>;
129
130/// Why a notification was not posted.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub enum NotificationError {
133    /// The user does not let the app notify.
134    Denied,
135    /// The system failed to post it.
136    Failed(String),
137}
138
139impl fmt::Display for NotificationError {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self {
142            NotificationError::Denied => f.write_str("the user does not let the app notify"),
143            NotificationError::Failed(reason) => write!(f, "the notification failed: {reason}"),
144        }
145    }
146}
147
148impl std::error::Error for NotificationError {}
149
150/// The user's permission to notify, as the system keeps it for the app and asks for it.
151pub trait NotificationPermission {
152    /// The permission as it stands, without asking.
153    fn state(&self) -> PermissionFuture;
154
155    /// Asks the user where they have not been asked, with the system's own question; answers the
156    /// permission after. Where they answered before, or the system has no question, it asks
157    /// nothing and answers the permission as it stands.
158    fn request(&self) -> PermissionFuture;
159}
160
161/// Where the user's permission stands.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum PermissionState {
164    Granted,
165    /// The user, or the system's settings, do not let the app notify.
166    Denied,
167    /// The user has not been asked, and the system would ask.
168    NotAsked,
169}
170
171pub type PermissionFuture = Pin<Box<dyn Future<Output = PermissionState>>>;
172
173/// Typed replies, where the system takes them.
174pub trait NotificationReplies {
175    /// An action that asks for a reply, shown as a field with `placeholder` in it.
176    fn reply_action(&self, id: &str, label: &str, placeholder: &str) -> NotificationAction {
177        NotificationAction::with_reply(id, label, placeholder)
178    }
179}
180
181/// Delivery at a later time, which the system holds whether or not the app runs.
182pub trait NotificationScheduling {
183    /// Shows `notification` at `at`, or at once where `at` has passed; a later post or schedule
184    /// under its id replaces it, and [`Notifications::remove`] cancels it. It never asks the user,
185    /// as a post does not.
186    fn schedule(&self, notification: &Notification, at: SystemTime) -> PostFuture;
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn a_reply_action_carries_its_placeholder_and_a_button_none() {
195        struct Replies;
196        impl NotificationReplies for Replies {}
197        let reply = Replies.reply_action("answer", "Reply", "Your answer");
198        assert_eq!(reply.reply_placeholder(), Some("Your answer"));
199        assert_eq!(
200            NotificationAction::button("later", "Later").reply_placeholder(),
201            None
202        );
203        let mut notification = Notification::new("message-7", "New message");
204        notification.actions.push(reply);
205        assert_eq!(notification.actions[0].id, "answer");
206    }
207}