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}