Skip to main content

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}