tauri_plugin_background_service/notifier.rs
1//! Thin wrapper around [`tauri_plugin_notification`] for fire-and-forget
2//! local notifications.
3//!
4//! Errors are logged but never propagated — callers should not need to
5//! handle notification failures.
6
7use crate::models::PluginConfig;
8use tauri::{AppHandle, Runtime};
9use tauri_plugin_notification::NotificationExt;
10
11/// Thin wrapper over `tauri-plugin-notification`.
12///
13/// Fire-and-forget: errors are logged via `log::warn!` and never propagated.
14#[derive(Clone)]
15pub struct Notifier<R: Runtime> {
16 pub(crate) app: AppHandle<R>,
17}
18
19impl<R: Runtime> Notifier<R> {
20 /// Show a local notification with the given title and body.
21 ///
22 /// Errors are logged but not returned — callers should not need to
23 /// handle notification failures.
24 pub fn show(&self, title: &str, body: &str) {
25 if let Err(e) = self
26 .app
27 .notification()
28 .builder()
29 .title(title)
30 .body(body)
31 .show()
32 {
33 log::warn!("background-service: notification failed: {e}");
34 }
35 }
36
37 /// Show a local notification with a stable string id.
38 ///
39 /// Repeated notifications with the same id replace the previous one
40 /// instead of stacking (platform-dependent best effort). Same warn-only
41 /// contract as [`Notifier::show`]: errors are logged, never propagated.
42 pub fn show_with_id(&self, id: &str, title: &str, body: &str) {
43 if let Err(e) = self
44 .app
45 .notification()
46 .builder()
47 .id(stable_notification_id(id))
48 .title(title)
49 .body(body)
50 .show()
51 {
52 log::warn!("background-service: notification {id} failed: {e}");
53 }
54 }
55}
56
57/// Map a string notification id onto the `i32` id the notification builder
58/// expects, deterministically (FNV-1a 32-bit), so the same string id keeps
59/// replacing the same notification across calls and process restarts.
60pub(crate) fn stable_notification_id(id: &str) -> i32 {
61 let mut hash: u32 = 0x811c_9dc5;
62 for byte in id.as_bytes() {
63 hash ^= u32::from(*byte);
64 hash = hash.wrapping_mul(0x0100_0193);
65 }
66 // Mask into `[1, i32::MAX]`. The FNV-1a `u32` space is roughly half
67 // negative when narrowed via `as i32`, which collides with platform
68 // conventions (Android `NotificationManager` ids are non-negative and
69 // 0 is treated as "no id" by some surfaces). Masking the sign bit and
70 // setting the low bit keeps the id positive, nonzero, and still
71 // deterministic.
72 ((hash & 0x7FFF_FFFF) as i32) | 1
73}
74
75/// Which plugin-side lifecycle notifications are enabled (spec 01 D1).
76///
77/// Derived once from [`PluginConfig`] at actor spawn via
78/// [`NotifierPolicy::derive`]; the default is everything off.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub struct NotifierPolicy {
81 /// Notify when the OS pauses background delivery (timeout/expiration).
82 pub on_timeout: bool,
83 /// Notify when background delivery is restored after OS restart/boot.
84 pub on_recovery: bool,
85}
86
87impl NotifierPolicy {
88 /// Derive the effective policy from config and platform (DEC-002).
89 ///
90 /// Pure function so the Android suppression matrix is host-testable;
91 /// the call site passes `cfg!(target_os = "android")`.
92 ///
93 /// Android suppression rules:
94 /// - `on_timeout` is forced off when `androidOnTimeout == "notifyUser"`,
95 /// because the Kotlin service already posts a native timeout
96 /// notification on that path.
97 /// - `on_recovery` is forced off unconditionally, because the native
98 /// BootReceiver recovery notification path is always active on Android.
99 pub fn derive(config: &PluginConfig, is_android: bool) -> Self {
100 let native_owns_timeout = is_android && config.android_on_timeout == "notifyUser";
101 Self {
102 on_timeout: config.notify_on_timeout && !native_owns_timeout,
103 on_recovery: config.notify_on_recovery && !is_android,
104 }
105 }
106}
107
108/// Dispatch seam for lifecycle notifications.
109///
110/// The manager actor talks to this trait instead of [`Notifier`] directly so
111/// tests can record notifications without a running Tauri app (the spec's
112/// test plan forbids `show()` calls in tests). The production sink is
113/// [`Notifier`] itself.
114pub trait NotifySink: Send + Sync {
115 /// Post a notification with replace-not-stack semantics for `id`.
116 fn notify(&self, id: &str, title: &str, body: &str);
117}
118
119impl<R: Runtime> NotifySink for Notifier<R> {
120 fn notify(&self, id: &str, title: &str, body: &str) {
121 self.show_with_id(id, title, body);
122 }
123}
124
125#[cfg(test)]
126#[allow(clippy::field_reassign_with_default)]
127mod tests {
128 use super::*;
129 use crate::models::PluginConfig;
130
131 /// Compile-time test: Notifier can be constructed and cloned from an AppHandle.
132 /// (Does not call show() because that requires a running Tauri app.)
133 #[allow(dead_code)]
134 fn notifier_clone_compiles<R: Runtime + Clone>(app: AppHandle<R>) {
135 let n = Notifier { app };
136 let _cloned = n.clone();
137 }
138
139 /// Compile-time test: show_with_id has the same warn-only, fire-and-forget
140 /// shape as show(). (Not called — requires a running Tauri app.)
141 #[allow(dead_code)]
142 fn notifier_show_with_id_compiles<R: Runtime>(n: &Notifier<R>) {
143 n.show_with_id("bg-timeout", "title", "body");
144 }
145
146 #[test]
147 fn stable_notification_id_is_deterministic() {
148 assert_eq!(
149 stable_notification_id("bg-timeout"),
150 stable_notification_id("bg-timeout")
151 );
152 assert_ne!(
153 stable_notification_id("bg-timeout"),
154 stable_notification_id("bg-recovery")
155 );
156 }
157
158 // CORE-06: stable ids must be non-negative and nonzero across a broad
159 // sample of inputs (the previous `u32 as i32` cast produced negative ids
160 // for ~50% of inputs).
161 #[test]
162 fn stable_notification_id_is_always_positive_and_nonzero() {
163 let mut seen = std::collections::HashSet::new();
164 for i in 0..1024u32 {
165 let key = format!("bg-key-{i}");
166 let id = stable_notification_id(&key);
167 assert!(id > 0, "id for {key} must be positive, got {id}");
168 // Determinism: same input still maps to a single id.
169 assert_eq!(id, stable_notification_id(&key));
170 seen.insert(id);
171 }
172 // Separation: 1024 distinct inputs must produce many distinct ids.
173 assert!(
174 seen.len() > 900,
175 "expected wide separation, got {} distinct ids",
176 seen.len()
177 );
178 }
179
180 /// Regression guard for the specific representative ids cited in CORE-06.
181 /// Pins the exact masked values so future changes to the hash surface
182 /// intentionally. Values computed from FNV-1a 32-bit.
183 #[test]
184 fn stable_notification_id_representative_values_are_pinned() {
185 // FNV-1a("bg-timeout") = 0x4E26122C → masked = 0x4E26122D.
186 // FNV-1a("bg-recovery") = 0xBD4CC8B2 → masked = 0x3D4CC8B3.
187 assert_eq!(stable_notification_id("bg-timeout"), 0x4E26_122D);
188 assert_eq!(stable_notification_id("bg-recovery"), 0x3D4C_C8B3);
189 // Both representative ids must be strictly positive and distinct.
190 let a = stable_notification_id("bg-timeout");
191 let b = stable_notification_id("bg-recovery");
192 assert!(a > 0 && b > 0);
193 assert_ne!(a, b);
194 }
195
196 // ── NotifierPolicy::derive — DEC-002 suppression matrix ──────────
197
198 fn config(
199 notify_on_timeout: bool,
200 notify_on_recovery: bool,
201 android_on_timeout: &str,
202 ) -> PluginConfig {
203 PluginConfig {
204 notify_on_timeout,
205 notify_on_recovery,
206 android_on_timeout: android_on_timeout.into(),
207 ..Default::default()
208 }
209 }
210
211 #[test]
212 fn derive_desktop_honors_configured_keys() {
213 let policy = NotifierPolicy::derive(&config(true, true, "notifyUser"), false);
214 assert_eq!(
215 policy,
216 NotifierPolicy {
217 on_timeout: true,
218 on_recovery: true
219 }
220 );
221 }
222
223 #[test]
224 fn derive_desktop_defaults_off() {
225 let policy = NotifierPolicy::derive(&config(false, false, "notifyUser"), false);
226 assert_eq!(
227 policy,
228 NotifierPolicy {
229 on_timeout: false,
230 on_recovery: false
231 }
232 );
233 }
234
235 #[test]
236 fn derive_android_notify_user_suppresses_timeout() {
237 // Kotlin LifecycleService already posts the native timeout
238 // notification when androidOnTimeout == "notifyUser" (DEC-002).
239 let policy = NotifierPolicy::derive(&config(true, true, "notifyUser"), true);
240 assert_eq!(
241 policy,
242 NotifierPolicy {
243 on_timeout: false,
244 on_recovery: false
245 }
246 );
247 }
248
249 #[test]
250 fn derive_android_stop_keeps_timeout() {
251 // androidOnTimeout == "stop" posts no native notification, so the
252 // plugin-side timeout notice is allowed; recovery stays suppressed.
253 let policy = NotifierPolicy::derive(&config(true, true, "stop"), true);
254 assert_eq!(
255 policy,
256 NotifierPolicy {
257 on_timeout: true,
258 on_recovery: false
259 }
260 );
261 }
262
263 #[test]
264 fn derive_android_schedule_recovery_keeps_timeout() {
265 let policy = NotifierPolicy::derive(&config(true, true, "scheduleRecovery"), true);
266 assert_eq!(
267 policy,
268 NotifierPolicy {
269 on_timeout: true,
270 on_recovery: false
271 }
272 );
273 }
274
275 #[test]
276 fn derive_android_always_suppresses_recovery() {
277 // The Kotlin BootReceiver recovery notification path is always
278 // active on Android and has no config switch (DEC-002).
279 let policy = NotifierPolicy::derive(&config(false, true, "stop"), true);
280 assert_eq!(
281 policy,
282 NotifierPolicy {
283 on_timeout: false,
284 on_recovery: false
285 }
286 );
287 }
288
289 #[test]
290 fn derive_default_policy_is_all_off() {
291 assert_eq!(
292 NotifierPolicy::default(),
293 NotifierPolicy {
294 on_timeout: false,
295 on_recovery: false
296 }
297 );
298 }
299}