Skip to main content

tauri_plugin_background_service/
models.rs

1//! Data types shared between the plugin's Rust core and the JS/Tauri layer.
2//!
3//! - [`ServiceContext`] is passed to every [`BackgroundService`](crate::BackgroundService) method.
4//! - [`StartConfig`] and [`PluginConfig`] control service and plugin behaviour.
5//! - [`PluginEvent`] represents events emitted to the JavaScript front-end.
6
7use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ServiceError;
12use crate::notifier::Notifier;
13
14/// The 14 valid Android foreground service types.
15///
16/// Sourced from <https://developer.android.com/about/versions/14/changes/fg-types>.
17/// Unknown types are rejected with [`ServiceError::Platform`] at both the Rust
18/// and Kotlin layers.
19pub const VALID_FOREGROUND_SERVICE_TYPES: &[&str] = &[
20    "dataSync",
21    "mediaPlayback",
22    "phoneCall",
23    "location",
24    "connectedDevice",
25    "mediaProjection",
26    "camera",
27    "microphone",
28    "health",
29    "remoteMessaging",
30    "systemExempted",
31    "shortService",
32    "specialUse",
33    "mediaProcessing",
34];
35
36/// Validate a foreground service type against the allowlist.
37///
38/// Returns `Ok(())` if the type is one of the 14 valid Android foreground
39/// service types, or `Err(ServiceError::Platform)` for unknown strings.
40pub fn validate_foreground_service_type(t: &str) -> Result<(), ServiceError> {
41    if VALID_FOREGROUND_SERVICE_TYPES.contains(&t) {
42        Ok(())
43    } else {
44        Err(ServiceError::Platform(format!(
45            "invalid foreground_service_type '{}'. Valid types: {:?}",
46            t, VALID_FOREGROUND_SERVICE_TYPES
47        )))
48    }
49}
50
51/// Validate a foreground service type against the plugin config allowlist.
52///
53/// Returns `Ok(())` if:
54/// - `validate` is `false` (skip check), OR
55/// - `fg_type` is empty (handled by [`validate_foreground_service_type`]), OR
56/// - `fg_type` matches an entry in `allowlist` (case-insensitive).
57///
58/// Returns `Err(ServiceError::Platform)` if:
59/// - `fg_type` is empty (when validate is true), OR
60/// - `fg_type` is not in the allowlist.
61pub fn validate_fg_type_against_allowlist(
62    fg_type: &str,
63    allowlist: &[String],
64    validate: bool,
65) -> Result<(), ServiceError> {
66    if !validate {
67        return Ok(());
68    }
69    if fg_type.is_empty() {
70        return Err(ServiceError::Platform(
71            "foreground_service_type must not be empty".into(),
72        ));
73    }
74    let fg_type_lower = fg_type.to_lowercase();
75    if allowlist.iter().any(|t| t.to_lowercase() == fg_type_lower) {
76        Ok(())
77    } else {
78        Err(ServiceError::Platform(format!(
79            "foreground_service_type '{}' is not allowed. Allowed types: {:?}",
80            fg_type, allowlist
81        )))
82    }
83}
84
85/// Passed into both `init` and `run`.
86/// Gives your service everything it needs to interact with the outside world.
87pub struct ServiceContext<R: Runtime> {
88    /// Fire a local notification. Works on all platforms.
89    pub notifier: Notifier<R>,
90
91    /// Emit an event to the JS UI layer.
92    pub app: tauri::AppHandle<R>,
93
94    /// Cancelled when `stopService()` is called.
95    pub shutdown: CancellationToken,
96
97    /// Text shown in the Android persistent notification.
98    /// Only available on mobile platforms.
99    #[cfg(mobile)]
100    pub service_label: String,
101
102    /// Android foreground service type (e.g. "dataSync", "specialUse").
103    /// Only available on mobile platforms.
104    #[cfg(mobile)]
105    pub foreground_service_type: String,
106}
107
108/// Optional startup configuration forwarded from JS through the plugin.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct StartConfig {
112    /// Text shown in the Android persistent foreground notification.
113    #[serde(default = "default_label", alias = "label")]
114    pub service_label: String,
115
116    /// Android foreground service type (e.g. "dataSync", "specialUse").
117    #[serde(default = "default_foreground_service_type")]
118    pub foreground_service_type: String,
119}
120
121fn default_label() -> String {
122    "Service running".into()
123}
124
125fn default_foreground_service_type() -> String {
126    "remoteMessaging".into()
127}
128
129/// Plugin-level configuration, deserialized from the Tauri plugin config.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PluginConfig {
133    /// iOS safety timeout in seconds for the expiration handler.
134    /// Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
135    #[serde(default = "default_ios_safety_timeout")]
136    pub ios_safety_timeout_secs: f64,
137
138    /// iOS cancel listener timeout in seconds.
139    /// Default: 14400 (4 hours). Balances leak risk vs. service lifetime.
140    #[serde(default = "default_ios_cancel_listener_timeout_secs")]
141    pub ios_cancel_listener_timeout_secs: u64,
142
143    /// iOS BGProcessingTask safety timeout in seconds.
144    /// Default: 0.0 (no cap). Processing tasks can run for minutes/hours,
145    /// so unlike BGAppRefreshTask (28s default), this defaults to uncapped.
146    /// Set to a positive value to impose a hard cap on processing task duration.
147    #[serde(default = "default_ios_processing_safety_timeout_secs")]
148    pub ios_processing_safety_timeout_secs: f64,
149
150    /// iOS `BGAppRefreshTask` earliest begin date in minutes from now.
151    /// Default: 15.0. Controls how soon iOS can launch the refresh task.
152    #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
153    pub ios_earliest_refresh_begin_minutes: f64,
154
155    /// iOS `BGProcessingTask` earliest begin date in minutes from now.
156    /// Default: 15.0. Controls how soon iOS can launch the processing task.
157    #[serde(default = "default_ios_earliest_processing_begin_minutes")]
158    pub ios_earliest_processing_begin_minutes: f64,
159
160    /// iOS `BGProcessingTask` requires external power.
161    /// Default: false. When true, iOS only launches the processing task
162    /// while the device is connected to power.
163    #[serde(default)]
164    pub ios_requires_external_power: bool,
165
166    /// iOS `BGProcessingTask` requires network connectivity.
167    /// Default: false. When true, iOS only launches the processing task
168    /// when the device has network access.
169    #[serde(default)]
170    pub ios_requires_network_connectivity: bool,
171
172    /// iOS adaptive `BGProcessingTask` scheduling ceiling, as a multiplier of
173    /// `ios_earliest_processing_begin_minutes`. Default: 4.0. The Swift
174    /// adaptive scheduler backs off the processing request's earliest begin
175    /// date after expired runs, never beyond `configured * multiplier`.
176    #[serde(default = "default_ios_processing_ceiling_multiplier")]
177    pub ios_processing_ceiling_multiplier: f64,
178
179    /// Capacity for the manager command channel (mpsc).
180    /// Default: 16. Increase for high-throughput scenarios with many
181    /// concurrent start/stop/is-running calls.
182    #[serde(default = "default_channel_capacity")]
183    pub channel_capacity: usize,
184
185    /// Android foreground service types allowed for `startService()`.
186    /// Default: `["remoteMessaging"]`. The preflight validation rejects any type
187    /// not in this list when `android_validate_foreground_service_type` is true.
188    #[serde(default = "default_android_foreground_service_types")]
189    pub android_foreground_service_types: Vec<String>,
190
191    /// Whether to validate the requested foreground service type against
192    /// `android_foreground_service_types` before starting the native service.
193    /// Default: true. Set to false to skip the allowlist check.
194    #[serde(default = "default_true")]
195    pub android_validate_foreground_service_type: bool,
196
197    /// Timeout policy for Android foreground service.
198    /// Values: "stop", "notifyUser" (default), "scheduleRecovery".
199    /// - "stop": set desiredRunning=false, stop service.
200    /// - "notifyUser": stop service, post timeout notification, keep desiredRunning=true.
201    /// - "scheduleRecovery": stop service, set recoveryPending=true, attempt reschedule.
202    #[serde(default = "default_android_on_timeout")]
203    pub android_on_timeout: String,
204
205    /// Android notification channel ID for the foreground service notification.
206    /// Default: "bg_service".
207    #[serde(default = "default_android_notification_channel_id")]
208    pub android_notification_channel_id: String,
209
210    /// Android notification channel name (visible to the user in system settings).
211    /// Default: "Background Service".
212    #[serde(default = "default_android_notification_channel_name")]
213    pub android_notification_channel_name: String,
214
215    /// Android notification ID for the foreground service notification.
216    /// Default: 9001.
217    #[serde(default = "default_android_notification_id")]
218    pub android_notification_id: u32,
219
220    /// Custom small icon resource name for the foreground notification.
221    /// When `None`, the system default (`android.R.drawable.ic_dialog_info`) is used.
222    #[serde(default)]
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub android_notification_small_icon: Option<String>,
225
226    /// Whether to show a stop action button on the foreground notification.
227    /// Default: true.
228    #[serde(default = "default_true")]
229    pub android_show_stop_action: bool,
230
231    /// Whether to automatically request POST_NOTIFICATIONS permission on
232    /// Android API 33+ when the plugin loads. Default: false (NTF-09: the
233    /// request is consented via an explainer rather than fired unconditionally
234    /// on load). Note the load-bearing gate lives in the Kotlin `load()` /
235    /// `optBoolean` fallback — Tauri forwards the RAW plugin config to mobile,
236    /// so this Rust default governs only the Rust-side `PluginConfig`.
237    #[serde(default)]
238    pub android_request_notification_permission_on_load: bool,
239
240    /// Post a local notification when the OS pauses background delivery
241    /// (platform timeout/expiration). Default: false (opt-in).
242    ///
243    /// On Android this is suppressed when `androidOnTimeout` is
244    /// `"notifyUser"`: the Kotlin service already posts a native timeout
245    /// notification there, and plugin-side notification ids cannot dedupe
246    /// against `NotificationManager` ids (DEC-002).
247    #[serde(default)]
248    pub notify_on_timeout: bool,
249
250    /// Post a local notification when background delivery is restored
251    /// after an OS restart or boot recovery. Default: false (opt-in).
252    ///
253    /// Always suppressed on Android: the native BootReceiver recovery
254    /// notification path is unconditionally active there, so a plugin-side
255    /// notice would double-notify (DEC-002).
256    #[serde(default)]
257    pub notify_on_recovery: bool,
258
259    /// Desktop service mode: "inProcess" (default) or "osService".
260    /// Controls whether the background service runs in-process or as a
261    /// registered OS service/daemon.
262    #[cfg(feature = "desktop-service")]
263    #[serde(default = "default_desktop_service_mode")]
264    pub desktop_service_mode: String,
265
266    /// Optional custom label for the desktop OS service registration.
267    /// When `None`, the label is auto-derived from the app identifier.
268    #[cfg(feature = "desktop-service")]
269    #[serde(default)]
270    pub desktop_service_label: Option<String>,
271
272    /// Whether the OS service should start automatically on boot (Linux) or
273    /// login (macOS). Only applies when `desktop_service_mode` is `"osService"`.
274    /// Default: false.
275    #[cfg(feature = "desktop-service")]
276    #[serde(default)]
277    pub desktop_service_autostart: bool,
278
279    /// When `true`, calling `startService()` will automatically start the OS
280    /// service if it is not already running (i.e. IPC is disconnected).
281    /// Only applies when `desktop_service_mode` is `"osService"`.
282    /// Default: false.
283    #[cfg(feature = "desktop-service")]
284    #[serde(default)]
285    pub desktop_start_service_if_missing: bool,
286
287    /// Timeout in milliseconds to wait for the IPC connection to become ready
288    /// after starting the OS service sidecar. Only applies when
289    /// `desktop_start_service_if_missing` is `true`.
290    /// Default: 5000 (5 seconds).
291    #[cfg(feature = "desktop-service")]
292    #[serde(default = "default_desktop_service_start_timeout_ms")]
293    pub desktop_service_start_timeout_ms: u64,
294}
295
296fn default_ios_safety_timeout() -> f64 {
297    28.0
298}
299
300fn default_ios_cancel_listener_timeout_secs() -> u64 {
301    14400
302}
303
304fn default_ios_processing_safety_timeout_secs() -> f64 {
305    0.0
306}
307
308fn default_ios_earliest_refresh_begin_minutes() -> f64 {
309    15.0
310}
311
312fn default_ios_earliest_processing_begin_minutes() -> f64 {
313    15.0
314}
315
316fn default_ios_processing_ceiling_multiplier() -> f64 {
317    4.0
318}
319
320fn default_android_foreground_service_types() -> Vec<String> {
321    vec!["remoteMessaging".into()]
322}
323
324fn default_android_on_timeout() -> String {
325    "notifyUser".into()
326}
327
328fn default_android_notification_channel_id() -> String {
329    "bg_service".into()
330}
331
332fn default_android_notification_channel_name() -> String {
333    "Background Service".into()
334}
335
336fn default_android_notification_id() -> u32 {
337    9001
338}
339
340fn default_true() -> bool {
341    true
342}
343
344fn default_channel_capacity() -> usize {
345    16
346}
347
348#[cfg(feature = "desktop-service")]
349fn default_desktop_service_mode() -> String {
350    "inProcess".into()
351}
352
353#[cfg(feature = "desktop-service")]
354fn default_desktop_service_start_timeout_ms() -> u64 {
355    5000
356}
357
358impl Default for StartConfig {
359    fn default() -> Self {
360        Self {
361            service_label: default_label(),
362            foreground_service_type: default_foreground_service_type(),
363        }
364    }
365}
366
367/// Lifecycle state of the background service.
368///
369/// Exposed via the `get-service-state` command to provide
370/// fine-grained status beyond a simple boolean.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373#[non_exhaustive]
374pub enum ServiceState {
375    /// No service has been started, or the last run has fully cleaned up.
376    Idle,
377    /// `init()` is in progress (between `Start` and successful `init()`).
378    Initializing,
379    /// `init()` succeeded; `run()` is executing.
380    Running,
381    /// Service stopped (by `stop()`, natural completion, or error).
382    Stopped,
383}
384
385/// Unified lifecycle state for the background service.
386///
387/// Provides fine-grained visibility into the service's current state,
388/// combining internal state, recovery status, and platform conditions.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub enum LifecycleState {
393    /// No service has been started.
394    Idle,
395    /// Service init() is in progress.
396    Starting,
397    /// Service run() is executing.
398    Running,
399    /// Service is being stopped (cancellation requested).
400    Stopping,
401    /// Service has stopped.
402    Stopped,
403    /// Service is recovering after a platform timeout or expiration.
404    Recovering,
405    /// Recovery is pending (waiting for platform conditions).
406    RecoveryPending,
407    /// Background execution window has expired (e.g. iOS BGTask).
408    Expired,
409    /// Service is blocked by a platform issue (e.g. missing permission).
410    Blocked,
411    /// Service encountered an error.
412    Error,
413    /// Core is running but account setup is needed (Android native state).
414    SetupIdle,
415    /// Core is running but account is locked, awaiting unlock (Android native state).
416    LockedIdle,
417}
418
419impl From<ServiceState> for LifecycleState {
420    fn from(state: ServiceState) -> Self {
421        match state {
422            ServiceState::Idle => LifecycleState::Idle,
423            ServiceState::Initializing => LifecycleState::Starting,
424            ServiceState::Running => LifecycleState::Running,
425            ServiceState::Stopped => LifecycleState::Stopped,
426        }
427    }
428}
429
430/// Native platform-side state reported by the OS service layer.
431///
432/// Reflects the state as observed by the Android foreground service, iOS
433/// BGTask handler, or desktop OS-service process — distinct from the
434/// plugin-internal [`ServiceState`].
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "camelCase")]
437#[non_exhaustive]
438pub enum NativeState {
439    Idle,
440    Starting,
441    Running,
442    Stopping,
443    Timeout,
444    Expired,
445    Recovering,
446    Error,
447}
448
449/// Snapshot of the service lifecycle status.
450///
451/// Returned by the `get-service-state` command.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct ServiceStatus {
455    /// Current lifecycle state.
456    pub state: ServiceState,
457    /// Last error message, if the service stopped due to an error.
458    pub last_error: Option<String>,
459
460    // --- Extended fields (Step 4) ---
461    /// Whether the service is desired to be running (persisted across restarts).
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub desired_running: Option<bool>,
464    /// Platform-native state as reported by the OS service layer.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub native_state: Option<NativeState>,
467    /// The lifecycle mechanism in use on the current platform.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub platform_mode: Option<LifecycleMode>,
470    /// Configuration used for the last successful start.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub last_start_config: Option<StartConfig>,
473    /// Epoch milliseconds of the last heartbeat received from the service.
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub last_heartbeat_at: Option<u64>,
476    /// How many restart attempts have been made since the last clean start.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub restart_attempt: Option<u32>,
479    /// Human-readable reason for the current recovery attempt.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub recovery_reason: Option<String>,
482    /// Last platform-specific error message.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub platform_error: Option<String>,
485}
486
487impl Default for ServiceStatus {
488    fn default() -> Self {
489        Self {
490            state: ServiceState::Idle,
491            last_error: None,
492            desired_running: None,
493            native_state: None,
494            platform_mode: None,
495            last_start_config: None,
496            last_heartbeat_at: None,
497            restart_attempt: None,
498            recovery_reason: None,
499            platform_error: None,
500        }
501    }
502}
503
504/// The operating system platform.
505///
506/// Returned by `get_platform_capabilities` to identify the current runtime environment.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509#[non_exhaustive]
510pub enum Platform {
511    Android,
512    Ios,
513    Windows,
514    Macos,
515    Linux,
516    Unknown,
517}
518
519/// Severity level for a validation issue.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522#[non_exhaustive]
523pub enum Severity {
524    Error,
525    Warning,
526    Info,
527}
528
529/// The lifecycle mechanism used by the plugin on the current platform.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532#[non_exhaustive]
533pub enum LifecycleMode {
534    AndroidForegroundService,
535    IosBgTaskScheduler,
536    DesktopInProcess,
537    DesktopOsService,
538}
539
540/// Guarantee level for a specific background execution scenario.
541///
542/// - `Guaranteed`: The platform reliably supports this scenario.
543/// - `BestEffort`: The platform may support this scenario but cannot guarantee it.
544/// - `Unsupported`: The platform does not support this scenario.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase")]
547#[non_exhaustive]
548pub enum LifecycleGuarantee {
549    Guaranteed,
550    BestEffort,
551    Unsupported,
552}
553
554/// Platform-specific background execution capabilities.
555///
556/// Returned by the `get_platform_capabilities` Tauri command. Provides honest
557/// reporting of what each platform can guarantee for background service survival.
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
559#[serde(rename_all = "camelCase")]
560#[non_exhaustive]
561pub struct PlatformCapabilities {
562    pub platform: Platform,
563    pub lifecycle_mode: LifecycleMode,
564    pub survives_app_close: LifecycleGuarantee,
565    pub survives_reboot: LifecycleGuarantee,
566    pub survives_force_quit: LifecycleGuarantee,
567    pub background_execution: LifecycleGuarantee,
568    pub limitations: Vec<String>,
569    pub required_setup: Vec<String>,
570}
571
572/// OS service install/running state.
573///
574/// Reported by [`OsServiceStatus`] to indicate whether the OS-level service
575/// (systemd, launchd, etc.) is installed and/or currently running.
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
577#[serde(rename_all = "camelCase")]
578#[non_exhaustive]
579pub enum OsServiceInstallState {
580    /// The OS service is not installed.
581    NotInstalled,
582    /// The OS service is installed but not currently running.
583    Installed,
584    /// The OS service is installed and currently running.
585    Running,
586}
587
588/// Snapshot of an OS-level service's status.
589///
590/// Returned by `get_os_service_status` to report the state of the desktop
591/// OS service (systemd user service, launchd agent).
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
593#[serde(rename_all = "camelCase")]
594#[non_exhaustive]
595pub struct OsServiceStatus {
596    /// The service label (e.g. `com.example.background-service`).
597    pub label: String,
598    /// The service manager kind (e.g. `systemd`, `launchd`).
599    pub mode: String,
600    /// Whether the service is installed and/or running.
601    pub installed: OsServiceInstallState,
602    /// Whether the IPC connection to the service sidecar is active.
603    pub ipc_connected: bool,
604    /// Path to the Unix domain socket used for IPC.
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub socket_path: Option<String>,
607    /// Last error message from the OS service, if any.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub last_error: Option<String>,
610}
611
612/// Reason why the background service stopped.
613///
614/// Structured stop reasons that distinguish between user-initiated stops,
615/// platform-imposed terminations, and natural task completion.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
617#[serde(rename_all = "camelCase")]
618#[non_exhaustive]
619pub enum StopReason {
620    /// User called stopService().
621    UserStop,
622    /// Application is shutting down gracefully.
623    AppStop,
624    /// Platform killed the service due to a timeout (e.g. Android FGS timeout).
625    PlatformTimeout,
626    /// Platform expired the background execution window (e.g. iOS BGTask).
627    PlatformExpiration,
628    /// User pressed stop on the native notification.
629    NativeNotificationStop,
630    /// OS restarted the service after a reboot.
631    OsRestart,
632    /// Service recovered after device boot.
633    BootRecovery,
634    /// Service's run() returned Ok(()) naturally.
635    TaskCompleted,
636    /// Service's run() returned an error.
637    Error,
638    /// The host process is exiting (e.g. iOS app backgrounded/terminated by the
639    /// OS). Not a user stop: desired state and BGTask scheduling are preserved so
640    /// recovery can resume background delivery (H2).
641    ProcessExit,
642}
643
644impl<'de> serde::Deserialize<'de> for StopReason {
645    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
646        let s = String::deserialize(deserializer)?;
647        match s.as_str() {
648            "userStop" => Ok(Self::UserStop),
649            "appStop" => Ok(Self::AppStop),
650            "platformTimeout" => Ok(Self::PlatformTimeout),
651            "platformExpiration" => Ok(Self::PlatformExpiration),
652            "nativeNotificationStop" => Ok(Self::NativeNotificationStop),
653            "osRestart" => Ok(Self::OsRestart),
654            "bootRecovery" => Ok(Self::BootRecovery),
655            "taskCompleted" => Ok(Self::TaskCompleted),
656            "error" => Ok(Self::Error),
657            "processExit" => Ok(Self::ProcessExit),
658            // Legacy string mappings for backward compatibility
659            "completed" => Ok(Self::TaskCompleted),
660            "cancelled" | "user" => Ok(Self::UserStop),
661            _ => Err(serde::de::Error::unknown_variant(
662                &s,
663                &[
664                    "userStop",
665                    "appStop",
666                    "platformTimeout",
667                    "platformExpiration",
668                    "nativeNotificationStop",
669                    "osRestart",
670                    "bootRecovery",
671                    "taskCompleted",
672                    "error",
673                    "processExit",
674                ],
675            )),
676        }
677    }
678}
679
680/// Native platform lifecycle events sent from Kotlin/Swift to the Rust actor.
681///
682/// These events originate in the native layer and are forwarded to the actor
683/// via [`ManagerCommand::NativeLifecycleEvent`](crate::manager::ManagerCommand).
684/// The actor maps each variant to the appropriate [`StopReason`].
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "camelCase", tag = "type")]
687#[non_exhaustive]
688pub enum NativeLifecycleEvent {
689    /// User pressed stop on the Android foreground service notification.
690    AndroidNotificationStop,
691    /// Android system killed the foreground service due to a timeout.
692    AndroidTimeout {
693        /// The foreground service type that timed out (e.g. "dataSync").
694        #[serde(skip_serializing_if = "Option::is_none")]
695        fgs_type: Option<String>,
696    },
697    /// Android restarted the service (START_STICKY) and the core start was
698    /// accepted — background delivery is restored.
699    AndroidOsRestartAccepted,
700    /// The service was recovered after device boot or package replacement
701    /// and the core start was accepted.
702    AndroidBootRecoveryAccepted,
703    /// The iOS `BGTask` execution window expired (the OS invoked the task's
704    /// expiration handler). Maps to [`StopReason::PlatformExpiration`]: the
705    /// BGTask schedule and desired state are preserved so recovery resumes
706    /// background delivery on the next window (H6).
707    IosBgTaskExpired,
708}
709
710impl NativeLifecycleEvent {
711    /// Map this native event to the corresponding [`StopReason`].
712    pub fn to_stop_reason(&self) -> StopReason {
713        match self {
714            Self::AndroidNotificationStop => StopReason::NativeNotificationStop,
715            Self::AndroidTimeout { .. } => StopReason::PlatformTimeout,
716            Self::AndroidOsRestartAccepted => StopReason::OsRestart,
717            Self::AndroidBootRecoveryAccepted => StopReason::BootRecovery,
718            Self::IosBgTaskExpired => StopReason::PlatformExpiration,
719        }
720    }
721
722    /// `true` for start-ACCEPTANCE events: the native layer restarted the
723    /// service and the core start succeeded. These are not stops — the
724    /// manager fires the recovery notification instead of tearing down.
725    pub fn is_recovery_acceptance(&self) -> bool {
726        matches!(
727            self,
728            Self::AndroidOsRestartAccepted | Self::AndroidBootRecoveryAccepted
729        )
730    }
731}
732
733/// Built-in event types emitted by the runner itself.
734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[serde(rename_all = "camelCase", tag = "type")]
736#[non_exhaustive]
737pub enum PluginEvent {
738    /// init() completed successfully
739    Started,
740    /// run() returned or was cancelled
741    Stopped { reason: StopReason },
742    /// init() or run() returned an error
743    Error { message: String },
744}
745
746impl Default for PluginConfig {
747    fn default() -> Self {
748        Self {
749            ios_safety_timeout_secs: default_ios_safety_timeout(),
750            ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
751            ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
752            ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
753            ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
754            ios_requires_external_power: false,
755            ios_requires_network_connectivity: false,
756            ios_processing_ceiling_multiplier: default_ios_processing_ceiling_multiplier(),
757            channel_capacity: default_channel_capacity(),
758            android_foreground_service_types: default_android_foreground_service_types(),
759            android_validate_foreground_service_type: default_true(),
760            android_on_timeout: default_android_on_timeout(),
761            android_notification_channel_id: default_android_notification_channel_id(),
762            android_notification_channel_name: default_android_notification_channel_name(),
763            android_notification_id: default_android_notification_id(),
764            android_notification_small_icon: None,
765            android_show_stop_action: default_true(),
766            android_request_notification_permission_on_load: false,
767            notify_on_timeout: false,
768            notify_on_recovery: false,
769            #[cfg(feature = "desktop-service")]
770            desktop_service_mode: default_desktop_service_mode(),
771            #[cfg(feature = "desktop-service")]
772            desktop_service_label: None,
773            #[cfg(feature = "desktop-service")]
774            desktop_service_autostart: false,
775            #[cfg(feature = "desktop-service")]
776            desktop_start_service_if_missing: false,
777            #[cfg(feature = "desktop-service")]
778            desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
779        }
780    }
781}
782
783// ── CORE-03: PluginConfig::validate ───────────────────────────────────
784//
785// `PluginConfig` is deserialized from the Tauri plugin config and consumed in
786// `init_with_service`'s setup hook. Several fields can panic at runtime if
787// allowed through unchecked (e.g. `mpsc::channel(0)` panics). Invalid values
788// for Android ids / modes / timers also reach native setup and produce
789// confusing late failures. `validate` runs once before any of those consumers
790// see the config and returns a typed error with a precise diagnostic.
791impl PluginConfig {
792    /// Validate every field used by the manager / native setup.
793    ///
794    /// Returns the first violation encountered as a `ServiceError::Platform`
795    pub fn validate(&self) -> Result<(), crate::error::ServiceError> {
796        use crate::error::ServiceError;
797        use ServiceError::Platform;
798
799        // ── iOS timeouts ───────────────────────────────────────────────
800        // safety_timeout is the BGAppRefreshTask cap — must be finite + > 0.
801        if !self.ios_safety_timeout_secs.is_finite() || self.ios_safety_timeout_secs <= 0.0 {
802            return Err(Platform(
803                "iosSafetyTimeoutSecs must be a finite positive number".into(),
804            ));
805        }
806        // cancel listener timeout must be > 0 (it is a u64, only 0 is invalid).
807        if self.ios_cancel_listener_timeout_secs == 0 {
808            return Err(Platform(
809                "iosCancelListenerTimeoutSecs must be greater than 0".into(),
810            ));
811        }
812        // processing safety timeout is OPTIONAL (0.0 = uncapped), but if
813        // supplied it must be finite and non-negative.
814        if !self.ios_processing_safety_timeout_secs.is_finite()
815            || self.ios_processing_safety_timeout_secs < 0.0
816        {
817            return Err(Platform(
818                "iosProcessingSafetyTimeoutSecs must be finite and non-negative (0 = uncapped)"
819                    .into(),
820            ));
821        }
822        // earliest begin minutes must be finite and non-negative for both
823        // task kinds (negative would backdate the request and be rejected
824        // by BGTaskScheduler).
825        for (name, value) in [
826            (
827                "iosEarliestRefreshBeginMinutes",
828                self.ios_earliest_refresh_begin_minutes,
829            ),
830            (
831                "iosEarliestProcessingBeginMinutes",
832                self.ios_earliest_processing_begin_minutes,
833            ),
834        ] {
835            if !value.is_finite() || value < 0.0 {
836                return Err(Platform(format!("{name} must be finite and non-negative")));
837            }
838        }
839        // processing ceiling multiplier must be finite and >= 1.
840        if !self.ios_processing_ceiling_multiplier.is_finite()
841            || self.ios_processing_ceiling_multiplier < 1.0
842        {
843            return Err(Platform(
844                "iosProcessingCeilingMultiplier must be finite and >= 1".into(),
845            ));
846        }
847
848        // ── Manager channel ─────────────────────────────────────────────
849        // mpsc::channel panics at capacity 0.
850        if self.channel_capacity == 0 {
851            return Err(Platform(
852                "channelCapacity must be greater than 0 (mpsc::channel panics at 0)".into(),
853            ));
854        }
855
856        // ── Android notification / channel ──────────────────────────────
857        // Android NotificationManager ids are non-negative i32; 0 is treated
858        // as "no id" on some surfaces. Tauri's notification builder accepts
859        // i32, so anything outside `1..=i32::MAX` is rejected.
860        if self.android_notification_id == 0 || self.android_notification_id > i32::MAX as u32 {
861            return Err(Platform(format!(
862                "androidNotificationId must be in 1..=i32::MAX (got {})",
863                self.android_notification_id
864            )));
865        }
866        if self.android_notification_channel_id.trim().is_empty() {
867            return Err(Platform(
868                "androidNotificationChannelId must be non-empty".into(),
869            ));
870        }
871        if self.android_notification_channel_name.trim().is_empty() {
872            return Err(Platform(
873                "androidNotificationChannelName must be non-empty".into(),
874            ));
875        }
876        // android_on_timeout policy must be a known vocabulary word.
877        match self.android_on_timeout.as_str() {
878            "stop" | "notifyUser" | "scheduleRecovery" => {}
879            other => {
880                return Err(Platform(format!(
881                    "androidOnTimeout must be one of stop|notifyUser|scheduleRecovery (got {other:?})"
882                )));
883            }
884        }
885
886        // ── Desktop-service mode + startup timeout ──────────────────────
887        #[cfg(feature = "desktop-service")]
888        {
889            match self.desktop_service_mode.as_str() {
890                "inProcess" | "osService" => {}
891                other => {
892                    return Err(Platform(format!(
893                        "desktopServiceMode must be inProcess|osService (got {other:?})"
894                    )));
895                }
896            }
897            if self.desktop_service_start_timeout_ms == 0 {
898                return Err(Platform(
899                    "desktopServiceStartTimeoutMs must be greater than 0".into(),
900                ));
901            }
902        }
903
904        Ok(())
905    }
906}
907
908/// Arguments sent to the native `startKeepalive` handler.
909///
910/// Lives in `models.rs` (not `mobile.rs`) so serde tests run on all platforms.
911#[derive(Debug, Serialize)]
912#[serde(rename_all = "camelCase")]
913#[allow(dead_code)]
914pub(crate) struct StartKeepaliveArgs<'a> {
915    pub label: &'a str,
916    pub foreground_service_type: &'a str,
917    /// iOS safety timeout in seconds. Only sent to iOS; `None` omits the key.
918    #[serde(skip_serializing_if = "Option::is_none")]
919    pub ios_safety_timeout_secs: Option<f64>,
920    /// iOS BGProcessingTask safety timeout in seconds. Only sent to iOS; `None` omits the key.
921    /// When `Some(positive)`, caps the processing task duration.
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub ios_processing_safety_timeout_secs: Option<f64>,
924    /// iOS BGAppRefreshTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub ios_earliest_refresh_begin_minutes: Option<f64>,
927    /// iOS BGProcessingTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
928    #[serde(skip_serializing_if = "Option::is_none")]
929    pub ios_earliest_processing_begin_minutes: Option<f64>,
930    /// iOS BGProcessingTask requires external power. Only sent to iOS; `None` omits the key.
931    #[serde(skip_serializing_if = "Option::is_none")]
932    pub ios_requires_external_power: Option<bool>,
933    /// iOS BGProcessingTask requires network connectivity. Only sent to iOS; `None` omits the key.
934    #[serde(skip_serializing_if = "Option::is_none")]
935    pub ios_requires_network_connectivity: Option<bool>,
936    /// iOS adaptive processing ceiling multiplier. Only sent to iOS; `None` omits the key.
937    #[serde(skip_serializing_if = "Option::is_none")]
938    pub ios_processing_ceiling_multiplier: Option<f64>,
939}
940
941/// Information about a pending iOS background task that launched the app.
942///
943/// Returned by `getPendingBgTask()` on iOS when the app was launched by iOS
944/// for a background task (BGAppRefreshTask or BGProcessingTask). Used by the
945/// Rust auto-start logic to detect OS-initiated launches and automatically
946/// start the service if `desired_running` is true.
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948#[serde(rename_all = "camelCase")]
949#[non_exhaustive]
950pub struct PendingTaskInfo {
951    /// The kind of background task: "refresh" or "processing".
952    pub task_kind: String,
953    /// The task identifier (e.g. "com.example.app.bg-refresh").
954    pub identifier: String,
955    /// Epoch timestamp (seconds) when the task was received by the native layer.
956    pub received_at: f64,
957    /// Epoch timestamp (seconds) when the pending task was consumed by the Rust
958    /// auto-start logic. `None` until `clear_pending_bg_task` is called.
959    #[serde(default)]
960    pub consumed_at: Option<f64>,
961}
962
963impl PendingTaskInfo {
964    /// Resolve a raw `getPendingBgTask` payload to an *active* pending task.
965    ///
966    /// Enforces the H5/M14 invariant `pending ⟺ consumedAt == nil`: a payload
967    /// with a null `taskKind` (no record) **or** a non-null `consumedAt`
968    /// (already consumed/stale) yields `None`; only a fresh, unconsumed record
969    /// is surfaced. This mirrors the Swift `getPendingBgTask` gate on the Rust
970    /// side so a consumed record can't re-arm a cold auto-start even if the
971    /// native payload still carries the keys — gating on `consumed_at`, not
972    /// only `taskKind.is_null()`.
973    pub fn from_pending_payload(
974        value: &serde_json::Value,
975    ) -> Result<Option<Self>, serde_json::Error> {
976        if value.get("taskKind").is_none_or(serde_json::Value::is_null) {
977            return Ok(None);
978        }
979        let info: Self = serde_json::from_value(value.clone())?;
980        if info.consumed_at.is_some() {
981            return Ok(None);
982        }
983        Ok(Some(info))
984    }
985}
986
987/// iOS scheduling status returned by the native layer.
988///
989/// Reports which background task types were successfully scheduled
990/// and any errors that occurred during scheduling. Returned by
991/// `get_scheduling_status` and parsed from `startKeepalive` on iOS.
992#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
993#[serde(rename_all = "camelCase")]
994#[non_exhaustive]
995pub struct IOSSchedulingStatus {
996    /// Whether a `BGAppRefreshTask` was successfully scheduled.
997    pub refresh_scheduled: bool,
998    /// Whether a `BGProcessingTask` was successfully scheduled.
999    pub processing_scheduled: bool,
1000    /// Error from `BGAppRefreshTask` scheduling, if any.
1001    #[serde(default)]
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub refresh_error: Option<String>,
1004    /// Error from `BGProcessingTask` scheduling, if any.
1005    #[serde(default)]
1006    #[serde(skip_serializing_if = "Option::is_none")]
1007    pub processing_error: Option<String>,
1008}
1009
1010/// Persisted iOS desired-state facts, queried from the native layer.
1011///
1012/// Returned by `getDesiredStateStatus` on iOS — the *persisted* half of the
1013/// status split (C1). Distinct from [`IOSSchedulingStatus`], which carries the
1014/// *submit-result* facts of the most recent scheduling attempt. The iOS
1015/// auto-start path reads `desired_running` + `last_start_config` from this DTO
1016/// (replacing the deleted untyped `get_scheduling_status_raw` bypass).
1017///
1018/// All fields except `desired_running` are genuinely optional: Swift resolves
1019/// them as `NSNull()` (JSON `null`) before any task has run, so they carry
1020/// `#[serde(default)]`. Timestamps are wall-clock seconds (`TimeInterval`),
1021/// which serialize as JSON floats.
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1023#[serde(rename_all = "camelCase")]
1024#[non_exhaustive]
1025pub struct IOSDesiredStateStatus {
1026    /// Whether the user desires the service running (persisted across launches).
1027    pub desired_running: bool,
1028    /// JSON-serialized `StartConfig` of the last successful start, if any.
1029    #[serde(default)]
1030    #[serde(skip_serializing_if = "Option::is_none")]
1031    pub last_start_config: Option<String>,
1032    /// Kind of the most recent BGTask: "refresh" or "processing".
1033    #[serde(default)]
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    pub last_task_kind: Option<String>,
1036    /// Wall-clock seconds when the most recent BGTask started, if any.
1037    #[serde(default)]
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    pub last_task_started_at: Option<f64>,
1040    /// Wall-clock seconds when the most recent BGTask completed, if any.
1041    #[serde(default)]
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub last_task_completed_at: Option<f64>,
1044    /// Last persisted scheduling error, if any.
1045    #[serde(default)]
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    pub last_schedule_error: Option<String>,
1048    /// Durable reason the most recent BGTask run ended ("completed" | "expired"),
1049    /// persisted under `ios_last_completion_reason`. Unlike the one-shot
1050    /// adaptation outcome (`ios_last_task_outcome`, consumed by `scheduleNext`),
1051    /// this is never wiped, so the "why did the last run end?" question (M7) is
1052    /// always answerable.
1053    #[serde(default)]
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub last_completion_reason: Option<String>,
1056    /// Whether notification authorization was granted on iOS (M4), persisted under
1057    /// `ios_notification_granted`. The deferred authorization request (service
1058    /// start, not plugin load) forwards its decision here so the Notifier can
1059    /// degrade when notifications are denied. `None` until the first
1060    /// notification-requiring intent has requested authorization.
1061    #[serde(default)]
1062    #[serde(skip_serializing_if = "Option::is_none")]
1063    pub notification_granted: Option<bool>,
1064}
1065
1066/// Notification-permission status (NTF-09), returned by the Android
1067/// `getNotificationPermissionStatus` / `requestNotificationPermission` Kotlin
1068/// commands. A permissive `status` string accommodates both vocabularies:
1069/// `getNotificationPermissionStatus` resolves `granted` | `notDetermined` |
1070/// `denied`, while the `requestNotificationPermission` `@PermissionCallback`
1071/// (Step 10a) resolves `granted` | `denied`.
1072#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1073#[non_exhaustive]
1074pub struct NotificationPermissionStatus {
1075    /// Current POST_NOTIFICATIONS authorization.
1076    pub status: String,
1077}
1078
1079/// A single setup issue found during validation.
1080///
1081/// Part of [`SetupValidationReport`]. Each issue has a machine-readable code,
1082/// a human-readable message, the platform it applies to, and an optional fix.
1083#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1084#[serde(rename_all = "camelCase")]
1085#[non_exhaustive]
1086pub struct SetupIssue {
1087    /// Machine-readable error code (e.g. "android_fgs_type").
1088    pub code: String,
1089    /// Human-readable description of the issue.
1090    pub message: String,
1091    /// The platform this issue applies to.
1092    pub platform: Platform,
1093    /// Suggested fix for the issue.
1094    #[serde(skip_serializing_if = "Option::is_none")]
1095    pub fix: Option<String>,
1096}
1097
1098impl SetupIssue {
1099    /// Convert this `SetupIssue` into a [`ValidationIssue`] with the given severity.
1100    pub fn to_validation_issue(&self, severity: Severity) -> ValidationIssue {
1101        ValidationIssue {
1102            severity,
1103            code: self.code.clone(),
1104            message: self.message.clone(),
1105            fix: self.fix.clone(),
1106            platform: self.platform,
1107        }
1108    }
1109}
1110
1111/// Result of validating background service setup prerequisites.
1112///
1113/// Returned by `validateBackgroundServiceSetup()`. Contains `errors` (blocking
1114/// issues that prevent the service from working) and `warnings` (non-blocking
1115/// issues that may cause degraded behavior).
1116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1117#[serde(rename_all = "camelCase")]
1118#[non_exhaustive]
1119pub struct SetupValidationReport {
1120    /// `true` when `errors` is empty (warnings do not affect this).
1121    pub ok: bool,
1122    /// Blocking issues that prevent the service from working correctly.
1123    pub errors: Vec<SetupIssue>,
1124    /// Non-blocking issues that may cause degraded behavior.
1125    pub warnings: Vec<SetupIssue>,
1126    /// Unified issues with typed severity.
1127    ///
1128    /// Combines `errors` (as [`Severity::Error`]) and `warnings` (as
1129    /// [`Severity::Warning`]) into a single list. Populated automatically
1130    /// by [`crate::validator::SetupValidator::validate`].
1131    #[serde(default)]
1132    pub issues: Vec<ValidationIssue>,
1133}
1134
1135/// A single validation issue found during lifecycle validation.
1136///
1137/// Each issue has a severity level, machine-readable code, human-readable
1138/// message, optional fix suggestion, and the platform it applies to.
1139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1140#[serde(rename_all = "camelCase")]
1141#[non_exhaustive]
1142pub struct ValidationIssue {
1143    pub severity: Severity,
1144    pub code: String,
1145    pub message: String,
1146    #[serde(skip_serializing_if = "Option::is_none")]
1147    pub fix: Option<String>,
1148    pub platform: Platform,
1149}
1150
1151/// Complete snapshot of the background service lifecycle status.
1152///
1153/// Provides a unified view of service state, desired state, recovery status,
1154/// platform capabilities, and validation issues.
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156#[serde(rename_all = "camelCase")]
1157#[non_exhaustive]
1158pub struct LifecycleStatus {
1159    pub state: LifecycleState,
1160    pub desired_running: bool,
1161    pub recovery_enabled: bool,
1162    pub recovery_pending: bool,
1163    #[serde(skip_serializing_if = "Option::is_none")]
1164    pub recovery_reason: Option<String>,
1165    #[serde(skip_serializing_if = "Option::is_none")]
1166    pub last_start_config: Option<StartConfig>,
1167    #[serde(skip_serializing_if = "Option::is_none")]
1168    pub last_platform_state: Option<String>,
1169    #[serde(skip_serializing_if = "Option::is_none")]
1170    pub last_platform_error: Option<String>,
1171    #[serde(skip_serializing_if = "Option::is_none")]
1172    pub last_error: Option<String>,
1173    pub platform: Platform,
1174    pub capabilities: PlatformCapabilities,
1175    pub issues: Vec<ValidationIssue>,
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    pub native_running: Option<bool>,
1178    #[serde(skip_serializing_if = "Option::is_none")]
1179    pub native_foreground: Option<bool>,
1180    #[serde(skip_serializing_if = "Option::is_none")]
1181    pub adopted: Option<bool>,
1182    #[serde(skip_serializing_if = "Option::is_none")]
1183    pub degraded: Option<bool>,
1184    #[serde(skip_serializing_if = "Option::is_none")]
1185    pub degraded_reason: Option<String>,
1186    #[serde(skip_serializing_if = "Option::is_none")]
1187    pub data_dir: Option<String>,
1188}
1189
1190/// Android native service state, bridged from Kotlin via `getAndroidServiceState`.
1191///
1192/// Mirrors the Kotlin `AndroidServiceState` data class field-for-field.
1193/// Only populated on Android (gated by `self.mobile.is_some()`).
1194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1195#[serde(rename_all = "camelCase")]
1196#[non_exhaustive]
1197pub struct AndroidServiceState {
1198    pub native_running: bool,
1199    pub native_foreground: bool,
1200    pub desired_running: bool,
1201    pub durable_state: String,
1202    #[serde(skip_serializing_if = "Option::is_none")]
1203    pub service_label: Option<String>,
1204    #[serde(skip_serializing_if = "Option::is_none")]
1205    pub foreground_service_type: Option<String>,
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub notification_id: Option<i64>,
1208    #[serde(skip_serializing_if = "Option::is_none")]
1209    pub notification_channel_id: Option<String>,
1210    pub recovery_pending: bool,
1211    #[serde(skip_serializing_if = "Option::is_none")]
1212    pub recovery_reason: Option<String>,
1213    #[serde(skip_serializing_if = "Option::is_none")]
1214    pub last_platform_error: Option<String>,
1215    pub data_dir: String,
1216}
1217
1218/// iOS native background-task state, assembled from the typed iOS status
1219/// queries (`getSchedulingStatus`, `getDesiredStateStatus`, `getPendingBgTask`).
1220///
1221/// Unlike Android — which runs a long-lived foreground service whose
1222/// `native_running` bit is authoritative — iOS has no persistent native
1223/// service: a `BGTask` executes for a short window and the Rust actor is the
1224/// runtime authority during it. This snapshot therefore reports *scheduling /
1225/// budget health* facts (H6) so `build_lifecycle_status` can surface the real
1226/// native situation (a pending task waiting, a scheduling error, an exhausted
1227/// budget) instead of relying on the actor's possibly-stale in-memory state.
1228/// Only populated on iOS (the mobile bridge tags it via `query_native_state`).
1229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1230#[serde(rename_all = "camelCase")]
1231#[non_exhaustive]
1232pub struct IosNativeState {
1233    /// Persisted desired-running intent (mirror of the Rust-backed authority).
1234    /// Answers the "desired?" status question.
1235    pub desired_running: bool,
1236    /// Whether a `BGAppRefreshTask` is currently scheduled (submit succeeded on
1237    /// the most recent attempt). Part of the "scheduled?" answer (M7).
1238    #[serde(default)]
1239    pub refresh_scheduled: bool,
1240    /// Whether a `BGProcessingTask` is currently scheduled. Part of the
1241    /// "scheduled?" answer (M7).
1242    #[serde(default)]
1243    pub processing_scheduled: bool,
1244    /// Kind ("refresh"/"processing") of the BGTask currently executing, or
1245    /// `None` when no task is running (idle / expired / between windows).
1246    /// Answers the "active?" question.
1247    #[serde(default)]
1248    #[serde(skip_serializing_if = "Option::is_none")]
1249    pub active_task_kind: Option<String>,
1250    /// An unconsumed pending BGTask that launched / woke the app, if any.
1251    /// Answers the "pending?" question.
1252    #[serde(default)]
1253    #[serde(skip_serializing_if = "Option::is_none")]
1254    pub pending_task: Option<PendingTaskInfo>,
1255    /// Wall-clock seconds when the most recent BGTask completed, if any.
1256    /// Answers the "last-completed?" question.
1257    #[serde(default)]
1258    #[serde(skip_serializing_if = "Option::is_none")]
1259    pub last_completed_at: Option<f64>,
1260    /// Durable reason the most recent BGTask run ended ("completed" | "expired").
1261    /// Answers the "why?" question (M7). Unlike the one-shot adaptation outcome,
1262    /// this survives the next `scheduleNext()` so the last completion is always
1263    /// reportable.
1264    #[serde(default)]
1265    #[serde(skip_serializing_if = "Option::is_none")]
1266    pub last_completion_reason: Option<String>,
1267    /// Error from the most recent `BGAppRefreshTask` scheduling attempt, if any.
1268    /// Half of the split "last-failed?" answer (M7) — kept distinct from the
1269    /// processing error rather than collapsed into one aggregate.
1270    #[serde(default)]
1271    #[serde(skip_serializing_if = "Option::is_none")]
1272    pub last_refresh_error: Option<String>,
1273    /// Error from the most recent `BGProcessingTask` scheduling attempt, if any.
1274    /// The other half of the split "last-failed?" answer (M7).
1275    #[serde(default)]
1276    #[serde(skip_serializing_if = "Option::is_none")]
1277    pub last_processing_error: Option<String>,
1278    /// Whether the app still has background-execution budget. `false` means iOS
1279    /// is unlikely to run further BGTasks (surfaced as degraded).
1280    pub in_budget: bool,
1281}
1282
1283#[cfg(test)]
1284#[allow(clippy::field_reassign_with_default)]
1285mod tests {
1286    use super::*;
1287
1288    // --- Default foreground service type tests ---
1289
1290    #[test]
1291    fn default_foreground_service_type_is_remote_messaging() {
1292        assert_eq!(default_foreground_service_type(), "remoteMessaging");
1293    }
1294
1295    #[test]
1296    fn default_android_foreground_service_types_is_remote_messaging() {
1297        assert_eq!(
1298            default_android_foreground_service_types(),
1299            vec!["remoteMessaging"]
1300        );
1301    }
1302
1303    #[test]
1304    fn start_config_default_uses_remote_messaging() {
1305        let config = StartConfig::default();
1306        assert_eq!(config.foreground_service_type, "remoteMessaging");
1307    }
1308
1309    // --- StartConfig tests ---
1310
1311    #[test]
1312    fn start_config_default_label() {
1313        let config = StartConfig::default();
1314        assert_eq!(config.service_label, "Service running");
1315    }
1316
1317    #[test]
1318    fn start_config_custom_label() {
1319        let config = StartConfig {
1320            service_label: "Syncing data".into(),
1321            ..Default::default()
1322        };
1323        assert_eq!(config.service_label, "Syncing data");
1324    }
1325
1326    #[test]
1327    fn start_config_serde_roundtrip_default() {
1328        let config = StartConfig::default();
1329        let json = serde_json::to_string(&config).unwrap();
1330        let de: StartConfig = serde_json::from_str(&json).unwrap();
1331        assert_eq!(de.service_label, config.service_label);
1332    }
1333
1334    #[test]
1335    fn start_config_serde_roundtrip_custom() {
1336        let config = StartConfig {
1337            service_label: "My service".into(),
1338            ..Default::default()
1339        };
1340        let json = serde_json::to_string(&config).unwrap();
1341        let de: StartConfig = serde_json::from_str(&json).unwrap();
1342        assert_eq!(de.service_label, "My service");
1343    }
1344
1345    #[test]
1346    fn start_config_deserialize_missing_field_uses_default() {
1347        // An empty JSON object should produce the default label
1348        let json = "{}";
1349        let de: StartConfig = serde_json::from_str(json).unwrap();
1350        assert_eq!(de.service_label, "Service running");
1351    }
1352
1353    #[test]
1354    fn start_config_json_key_is_camel_case() {
1355        let config = StartConfig {
1356            service_label: "test".into(),
1357            ..Default::default()
1358        };
1359        let json = serde_json::to_string(&config).unwrap();
1360        assert!(
1361            json.contains("serviceLabel"),
1362            "JSON should use camelCase: {json}"
1363        );
1364    }
1365
1366    // --- StartConfig alias tests ---
1367
1368    #[test]
1369    fn start_config_legacy_label_alias_decodes() {
1370        let json = r#"{"label":"Legacy name"}"#;
1371        let de: StartConfig = serde_json::from_str(json).unwrap();
1372        assert_eq!(de.service_label, "Legacy name");
1373    }
1374
1375    #[test]
1376    fn start_config_both_label_and_service_label_rejected() {
1377        let json = r#"{"serviceLabel":"New name","label":"Old name"}"#;
1378        let result = serde_json::from_str::<StartConfig>(json);
1379        assert!(result.is_err(), "should reject duplicate field via alias");
1380    }
1381
1382    #[test]
1383    fn start_config_unknown_fields_ignored() {
1384        let json = r#"{"serviceLabel":"test","unknownField":42,"extra":"data"}"#;
1385        let de: StartConfig = serde_json::from_str(json).unwrap();
1386        assert_eq!(de.service_label, "test");
1387        assert_eq!(de.foreground_service_type, "remoteMessaging");
1388    }
1389
1390    #[test]
1391    fn start_config_camel_case_key_still_works() {
1392        let json = r#"{"serviceLabel":"Modern name"}"#;
1393        let de: StartConfig = serde_json::from_str(json).unwrap();
1394        assert_eq!(de.service_label, "Modern name");
1395    }
1396
1397    // --- PluginEvent tests ---
1398
1399    #[test]
1400    fn plugin_event_started_serde_roundtrip() {
1401        let event = PluginEvent::Started;
1402        let json = serde_json::to_string(&event).unwrap();
1403        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1404        assert!(matches!(de, PluginEvent::Started));
1405    }
1406
1407    #[test]
1408    fn plugin_event_stopped_serde_roundtrip() {
1409        let event = PluginEvent::Stopped {
1410            reason: StopReason::UserStop,
1411        };
1412        let json = serde_json::to_string(&event).unwrap();
1413        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1414        match de {
1415            PluginEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
1416            other => panic!("Expected Stopped, got {other:?}"),
1417        }
1418    }
1419
1420    #[test]
1421    fn plugin_event_error_serde_roundtrip() {
1422        let event = PluginEvent::Error {
1423            message: "init failed".into(),
1424        };
1425        let json = serde_json::to_string(&event).unwrap();
1426        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1427        match de {
1428            PluginEvent::Error { message } => assert_eq!(message, "init failed"),
1429            other => panic!("Expected Error, got {other:?}"),
1430        }
1431    }
1432
1433    #[test]
1434    fn plugin_event_tagged_json_format() {
1435        let event = PluginEvent::Started;
1436        let json = serde_json::to_string(&event).unwrap();
1437        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
1438    }
1439
1440    #[test]
1441    fn plugin_event_stopped_json_keys_camel_case() {
1442        let event = PluginEvent::Stopped {
1443            reason: StopReason::TaskCompleted,
1444        };
1445        let json = serde_json::to_string(&event).unwrap();
1446        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
1447        assert!(
1448            json.contains("\"reason\":\"taskCompleted\""),
1449            "Reason: {json}"
1450        );
1451    }
1452
1453    #[test]
1454    fn plugin_event_error_json_keys_camel_case() {
1455        let event = PluginEvent::Error {
1456            message: "oops".into(),
1457        };
1458        let json = serde_json::to_string(&event).unwrap();
1459        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
1460        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
1461    }
1462
1463    // --- StopReason tests ---
1464
1465    #[test]
1466    fn stop_reason_all_variants_serialize_to_camel_case() {
1467        assert_eq!(
1468            serde_json::to_string(&StopReason::UserStop).unwrap(),
1469            "\"userStop\""
1470        );
1471        assert_eq!(
1472            serde_json::to_string(&StopReason::AppStop).unwrap(),
1473            "\"appStop\""
1474        );
1475        assert_eq!(
1476            serde_json::to_string(&StopReason::PlatformTimeout).unwrap(),
1477            "\"platformTimeout\""
1478        );
1479        assert_eq!(
1480            serde_json::to_string(&StopReason::PlatformExpiration).unwrap(),
1481            "\"platformExpiration\""
1482        );
1483        assert_eq!(
1484            serde_json::to_string(&StopReason::NativeNotificationStop).unwrap(),
1485            "\"nativeNotificationStop\""
1486        );
1487        assert_eq!(
1488            serde_json::to_string(&StopReason::OsRestart).unwrap(),
1489            "\"osRestart\""
1490        );
1491        assert_eq!(
1492            serde_json::to_string(&StopReason::BootRecovery).unwrap(),
1493            "\"bootRecovery\""
1494        );
1495        assert_eq!(
1496            serde_json::to_string(&StopReason::TaskCompleted).unwrap(),
1497            "\"taskCompleted\""
1498        );
1499        assert_eq!(
1500            serde_json::to_string(&StopReason::Error).unwrap(),
1501            "\"error\""
1502        );
1503        assert_eq!(
1504            serde_json::to_string(&StopReason::ProcessExit).unwrap(),
1505            "\"processExit\""
1506        );
1507    }
1508
1509    #[test]
1510    fn stop_reason_roundtrip_all_variants() {
1511        for variant in [
1512            StopReason::UserStop,
1513            StopReason::AppStop,
1514            StopReason::PlatformTimeout,
1515            StopReason::PlatformExpiration,
1516            StopReason::NativeNotificationStop,
1517            StopReason::OsRestart,
1518            StopReason::BootRecovery,
1519            StopReason::TaskCompleted,
1520            StopReason::Error,
1521            StopReason::ProcessExit,
1522        ] {
1523            let json = serde_json::to_string(&variant).unwrap();
1524            let de: StopReason = serde_json::from_str(&json).unwrap();
1525            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
1526        }
1527    }
1528
1529    #[test]
1530    fn stop_reason_process_exit_deserializes_from_camel_case() {
1531        // H2: OS-driven process exit is a distinct, non-user stop reason.
1532        let de: StopReason = serde_json::from_str("\"processExit\"").unwrap();
1533        assert_eq!(de, StopReason::ProcessExit);
1534    }
1535
1536    #[test]
1537    fn stop_reason_legacy_completed_maps_to_task_completed() {
1538        let json = "\"completed\"";
1539        let de: StopReason = serde_json::from_str(json).unwrap();
1540        assert_eq!(de, StopReason::TaskCompleted);
1541    }
1542
1543    #[test]
1544    fn stop_reason_legacy_cancelled_maps_to_user_stop() {
1545        let json = "\"cancelled\"";
1546        let de: StopReason = serde_json::from_str(json).unwrap();
1547        assert_eq!(de, StopReason::UserStop);
1548    }
1549
1550    #[test]
1551    fn stop_reason_legacy_user_maps_to_user_stop() {
1552        let json = "\"user\"";
1553        let de: StopReason = serde_json::from_str(json).unwrap();
1554        assert_eq!(de, StopReason::UserStop);
1555    }
1556
1557    #[test]
1558    fn stop_reason_unknown_variant_returns_error() {
1559        let json = "\"unknownReason\"";
1560        let result = serde_json::from_str::<StopReason>(json);
1561        assert!(
1562            result.is_err(),
1563            "unknown variant should fail to deserialize"
1564        );
1565    }
1566
1567    // --- NativeLifecycleEvent tests ---
1568
1569    #[test]
1570    fn native_lifecycle_event_android_notification_stop_roundtrip() {
1571        let event = NativeLifecycleEvent::AndroidNotificationStop;
1572        let json = serde_json::to_string(&event).unwrap();
1573        assert_eq!(json, r#"{"type":"androidNotificationStop"}"#);
1574        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1575        assert_eq!(de, event);
1576    }
1577
1578    #[test]
1579    fn native_lifecycle_event_android_timeout_roundtrip() {
1580        let event = NativeLifecycleEvent::AndroidTimeout {
1581            fgs_type: Some("dataSync".into()),
1582        };
1583        let json = serde_json::to_string(&event).unwrap();
1584        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1585        assert_eq!(de, event);
1586    }
1587
1588    #[test]
1589    fn native_lifecycle_event_android_timeout_without_fgs_type() {
1590        let event = NativeLifecycleEvent::AndroidTimeout { fgs_type: None };
1591        let json = serde_json::to_string(&event).unwrap();
1592        // skip_serializing_if means fgs_type is omitted when None
1593        assert!(!json.contains("fgsType"), "{json}");
1594        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1595        assert_eq!(de, event);
1596    }
1597
1598    #[test]
1599    fn native_lifecycle_event_to_stop_reason_mapping() {
1600        assert_eq!(
1601            NativeLifecycleEvent::AndroidNotificationStop.to_stop_reason(),
1602            StopReason::NativeNotificationStop
1603        );
1604        assert_eq!(
1605            NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.to_stop_reason(),
1606            StopReason::PlatformTimeout
1607        );
1608        assert_eq!(
1609            NativeLifecycleEvent::AndroidTimeout {
1610                fgs_type: Some("dataSync".into())
1611            }
1612            .to_stop_reason(),
1613            StopReason::PlatformTimeout
1614        );
1615    }
1616
1617    #[test]
1618    fn native_lifecycle_event_recovery_acceptance_roundtrips() {
1619        let event = NativeLifecycleEvent::AndroidOsRestartAccepted;
1620        let json = serde_json::to_string(&event).unwrap();
1621        assert_eq!(json, r#"{"type":"androidOsRestartAccepted"}"#);
1622        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1623        assert_eq!(de, event);
1624
1625        let event = NativeLifecycleEvent::AndroidBootRecoveryAccepted;
1626        let json = serde_json::to_string(&event).unwrap();
1627        assert_eq!(json, r#"{"type":"androidBootRecoveryAccepted"}"#);
1628        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1629        assert_eq!(de, event);
1630    }
1631
1632    #[test]
1633    fn native_lifecycle_event_recovery_acceptance_stop_reasons() {
1634        assert_eq!(
1635            NativeLifecycleEvent::AndroidOsRestartAccepted.to_stop_reason(),
1636            StopReason::OsRestart
1637        );
1638        assert_eq!(
1639            NativeLifecycleEvent::AndroidBootRecoveryAccepted.to_stop_reason(),
1640            StopReason::BootRecovery
1641        );
1642    }
1643
1644    #[test]
1645    fn native_lifecycle_event_recovery_acceptance_classification() {
1646        assert!(NativeLifecycleEvent::AndroidOsRestartAccepted.is_recovery_acceptance());
1647        assert!(NativeLifecycleEvent::AndroidBootRecoveryAccepted.is_recovery_acceptance());
1648        assert!(!NativeLifecycleEvent::AndroidNotificationStop.is_recovery_acceptance());
1649        assert!(!NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.is_recovery_acceptance());
1650    }
1651
1652    /// H6: the iOS BGTask-expiration event maps to `PlatformExpiration` (so the
1653    /// schedule + desired state survive), is not a recovery acceptance, and
1654    /// round-trips through the camelCase wire format.
1655    #[test]
1656    fn native_lifecycle_event_ios_bg_task_expired() {
1657        let event = NativeLifecycleEvent::IosBgTaskExpired;
1658        assert_eq!(event.to_stop_reason(), StopReason::PlatformExpiration);
1659        assert!(!event.is_recovery_acceptance());
1660
1661        let json = serde_json::to_string(&event).unwrap();
1662        assert_eq!(json, r#"{"type":"iosBgTaskExpired"}"#);
1663        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1664        assert_eq!(de, event);
1665    }
1666
1667    /// H6: `IosNativeState` round-trips through camelCase JSON, including the
1668    /// nested pending task and the optional health fields.
1669    #[test]
1670    fn ios_native_state_serde_roundtrip() {
1671        let state = IosNativeState {
1672            desired_running: true,
1673            refresh_scheduled: true,
1674            processing_scheduled: false,
1675            active_task_kind: Some("refresh".into()),
1676            pending_task: Some(PendingTaskInfo {
1677                task_kind: "processing".into(),
1678                identifier: "com.example.app.bg-processing".into(),
1679                received_at: 1000.0,
1680                consumed_at: None,
1681            }),
1682            last_completed_at: Some(900.0),
1683            last_completion_reason: Some("completed".into()),
1684            last_refresh_error: Some("BGTaskSchedulerErrorDomain code 1".into()),
1685            last_processing_error: None,
1686            in_budget: false,
1687        };
1688        let json = serde_json::to_string(&state).unwrap();
1689        assert!(json.contains("\"desiredRunning\":true"));
1690        assert!(json.contains("\"refreshScheduled\":true"));
1691        assert!(json.contains("\"activeTaskKind\":\"refresh\""));
1692        assert!(json.contains("\"inBudget\":false"));
1693        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1694        assert_eq!(de, state);
1695    }
1696
1697    /// H6: optional health fields default to `None` when absent from the wire.
1698    #[test]
1699    fn ios_native_state_defaults_optional_fields() {
1700        let json = r#"{"desiredRunning":false,"inBudget":true}"#;
1701        let de: IosNativeState = serde_json::from_str(json).unwrap();
1702        assert!(!de.desired_running);
1703        assert!(de.in_budget);
1704        assert!(!de.refresh_scheduled);
1705        assert!(!de.processing_scheduled);
1706        assert_eq!(de.active_task_kind, None);
1707        assert_eq!(de.pending_task, None);
1708        assert_eq!(de.last_completed_at, None);
1709        assert_eq!(de.last_completion_reason, None);
1710        assert_eq!(de.last_refresh_error, None);
1711        assert_eq!(de.last_processing_error, None);
1712    }
1713
1714    /// M7: one `IosNativeState` snapshot answers all seven status questions —
1715    /// desired? scheduled? pending? active? last-completed? last-failed? why?
1716    /// Each question maps to an explicit, non-derived field on the snapshot.
1717    #[test]
1718    fn ios_native_state_answers_seven_questions() {
1719        let state = IosNativeState {
1720            desired_running: true,                    // desired?
1721            refresh_scheduled: true,                  // scheduled?
1722            processing_scheduled: true,               // scheduled?
1723            active_task_kind: Some("refresh".into()), // active?
1724            pending_task: Some(PendingTaskInfo {
1725                // pending?
1726                task_kind: "refresh".into(),
1727                identifier: "com.example.app.bg-refresh".into(),
1728                received_at: 1000.0,
1729                consumed_at: None,
1730            }),
1731            last_completed_at: Some(950.0), // last-completed?
1732            last_completion_reason: Some("completed".into()), // why?
1733            last_refresh_error: Some("refresh boom".into()), // last-failed? (refresh)
1734            last_processing_error: Some("processing boom".into()), // last-failed? (processing)
1735            in_budget: false,
1736        };
1737
1738        // 1 desired?      2 scheduled?                        3 pending?
1739        assert!(state.desired_running);
1740        assert!(state.refresh_scheduled && state.processing_scheduled);
1741        assert!(state.pending_task.is_some());
1742        // 4 active?       5 last-completed?                   7 why?
1743        assert_eq!(state.active_task_kind.as_deref(), Some("refresh"));
1744        assert_eq!(state.last_completed_at, Some(950.0));
1745        assert_eq!(state.last_completion_reason.as_deref(), Some("completed"));
1746        // 6 last-failed? — split per task type, not a single aggregate.
1747        assert_eq!(state.last_refresh_error.as_deref(), Some("refresh boom"));
1748        assert_eq!(
1749            state.last_processing_error.as_deref(),
1750            Some("processing boom")
1751        );
1752    }
1753
1754    /// M7 (req 2): the two scheduling errors round-trip independently — a refresh
1755    /// failure and a processing failure are distinguishable on the wire, not
1756    /// collapsed into one aggregate `lastScheduleError`.
1757    #[test]
1758    fn ios_native_state_splits_schedule_errors() {
1759        let state = IosNativeState {
1760            desired_running: true,
1761            refresh_scheduled: false,
1762            processing_scheduled: true,
1763            active_task_kind: None,
1764            pending_task: None,
1765            last_completed_at: None,
1766            last_completion_reason: None,
1767            last_refresh_error: Some("only refresh failed".into()),
1768            last_processing_error: None,
1769            in_budget: true,
1770        };
1771        let json = serde_json::to_string(&state).unwrap();
1772        assert!(
1773            json.contains("\"lastRefreshError\":\"only refresh failed\""),
1774            "{json}"
1775        );
1776        // The processing error is absent (skip_serializing_if None), proving the
1777        // split: a refresh-only failure does not surface as a processing error.
1778        assert!(!json.contains("lastProcessingError"), "{json}");
1779        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1780        assert_eq!(de, state);
1781        assert_eq!(
1782            de.last_refresh_error.as_deref(),
1783            Some("only refresh failed")
1784        );
1785        assert_eq!(de.last_processing_error, None);
1786    }
1787
1788    /// M7 (req 3): the durable last-completion reason round-trips through the
1789    /// camelCase wire as `lastCompletionReason`.
1790    #[test]
1791    fn ios_native_state_carries_last_completion_reason() {
1792        let state = IosNativeState {
1793            desired_running: true,
1794            refresh_scheduled: true,
1795            processing_scheduled: true,
1796            active_task_kind: None,
1797            pending_task: None,
1798            last_completed_at: Some(900.0),
1799            last_completion_reason: Some("expired".into()),
1800            last_refresh_error: None,
1801            last_processing_error: None,
1802            in_budget: true,
1803        };
1804        let json = serde_json::to_string(&state).unwrap();
1805        assert!(
1806            json.contains("\"lastCompletionReason\":\"expired\""),
1807            "{json}"
1808        );
1809        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1810        assert_eq!(de.last_completion_reason.as_deref(), Some("expired"));
1811    }
1812
1813    #[test]
1814    fn plugin_event_stopped_with_stop_reason_roundtrip() {
1815        let event = PluginEvent::Stopped {
1816            reason: StopReason::TaskCompleted,
1817        };
1818        let json = serde_json::to_string(&event).unwrap();
1819        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1820        assert_eq!(
1821            de,
1822            PluginEvent::Stopped {
1823                reason: StopReason::TaskCompleted
1824            }
1825        );
1826    }
1827
1828    #[test]
1829    fn plugin_event_stopped_legacy_reason_deserializes() {
1830        // Simulates receiving a legacy event with reason "completed"
1831        let json = r#"{"type":"stopped","reason":"completed"}"#;
1832        let de: PluginEvent = serde_json::from_str(json).unwrap();
1833        match de {
1834            PluginEvent::Stopped { reason } => {
1835                assert_eq!(reason, StopReason::TaskCompleted);
1836            }
1837            other => panic!("Expected Stopped, got {other:?}"),
1838        }
1839    }
1840
1841    #[test]
1842    fn plugin_event_stopped_legacy_cancelled_deserializes() {
1843        let json = r#"{"type":"stopped","reason":"cancelled"}"#;
1844        let de: PluginEvent = serde_json::from_str(json).unwrap();
1845        match de {
1846            PluginEvent::Stopped { reason } => {
1847                assert_eq!(reason, StopReason::UserStop);
1848            }
1849            other => panic!("Expected Stopped, got {other:?}"),
1850        }
1851    }
1852
1853    // --- StartConfig foreground_service_type tests ---
1854
1855    #[test]
1856    fn start_config_default_service_type() {
1857        let config = StartConfig::default();
1858        assert_eq!(config.foreground_service_type, "remoteMessaging");
1859    }
1860
1861    #[test]
1862    fn start_config_custom_service_type() {
1863        let config = StartConfig {
1864            service_label: "test".into(),
1865            foreground_service_type: "specialUse".into(),
1866        };
1867        assert_eq!(config.foreground_service_type, "specialUse");
1868    }
1869
1870    #[test]
1871    fn start_config_serde_roundtrip_service_type() {
1872        let config = StartConfig {
1873            service_label: "test".into(),
1874            foreground_service_type: "specialUse".into(),
1875        };
1876        let json = serde_json::to_string(&config).unwrap();
1877        let de: StartConfig = serde_json::from_str(&json).unwrap();
1878        assert_eq!(de.foreground_service_type, "specialUse");
1879    }
1880
1881    #[test]
1882    fn start_config_deserialize_missing_service_type() {
1883        let json = r#"{"serviceLabel":"test"}"#;
1884        let de: StartConfig = serde_json::from_str(json).unwrap();
1885        assert_eq!(de.foreground_service_type, "remoteMessaging");
1886    }
1887
1888    #[test]
1889    fn start_config_deserialize_special_use() {
1890        let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
1891        let de: StartConfig = serde_json::from_str(json).unwrap();
1892        assert_eq!(de.foreground_service_type, "specialUse");
1893    }
1894
1895    #[test]
1896    fn start_config_unrecognized_type_rejected_by_validation() {
1897        // Deserialization still passes through any string.
1898        let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
1899        let de: StartConfig = serde_json::from_str(json).unwrap();
1900        assert_eq!(de.foreground_service_type, "customType");
1901        // But validation rejects it.
1902        let result = validate_foreground_service_type(&de.foreground_service_type);
1903        assert!(
1904            result.is_err(),
1905            "validation should reject unrecognized type"
1906        );
1907        let err_msg = result.unwrap_err().to_string();
1908        assert!(
1909            err_msg.contains("customType"),
1910            "error should mention the invalid type: {err_msg}"
1911        );
1912    }
1913
1914    #[test]
1915    fn start_config_json_key_is_camel_case_service_type() {
1916        let config = StartConfig {
1917            service_label: "test".into(),
1918            foreground_service_type: "specialUse".into(),
1919        };
1920        let json = serde_json::to_string(&config).unwrap();
1921        assert!(
1922            json.contains("foregroundServiceType"),
1923            "JSON should use camelCase: {json}"
1924        );
1925    }
1926
1927    // --- PluginConfig tests ---
1928
1929    #[test]
1930    fn plugin_config_default_ios_safety_timeout() {
1931        let json = "{}";
1932        let config: PluginConfig = serde_json::from_str(json).unwrap();
1933        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1934    }
1935
1936    #[test]
1937    fn plugin_config_custom_ios_safety_timeout() {
1938        let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
1939        let config: PluginConfig = serde_json::from_str(json).unwrap();
1940        assert_eq!(config.ios_safety_timeout_secs, 15.0);
1941    }
1942
1943    #[test]
1944    fn plugin_config_serde_roundtrip_preserves_value() {
1945        let config = PluginConfig {
1946            ios_safety_timeout_secs: 30.0,
1947            ios_cancel_listener_timeout_secs: 14400,
1948            ios_processing_safety_timeout_secs: 0.0,
1949            ios_earliest_refresh_begin_minutes: 20.0,
1950            ios_earliest_processing_begin_minutes: 30.0,
1951            ios_requires_external_power: true,
1952            ios_requires_network_connectivity: true,
1953            ..Default::default()
1954        };
1955        let json = serde_json::to_string(&config).unwrap();
1956        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1957        assert_eq!(de.ios_safety_timeout_secs, 30.0);
1958        assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
1959        assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
1960        assert!(de.ios_requires_external_power);
1961        assert!(de.ios_requires_network_connectivity);
1962    }
1963
1964    #[test]
1965    fn plugin_config_default_impl() {
1966        let config = PluginConfig::default();
1967        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1968        assert_eq!(config.channel_capacity, 16);
1969    }
1970
1971    #[test]
1972    fn plugin_config_default_cancel_timeout() {
1973        let json = "{}";
1974        let config: PluginConfig = serde_json::from_str(json).unwrap();
1975        assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
1976    }
1977
1978    #[test]
1979    fn plugin_config_custom_cancel_timeout() {
1980        let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
1981        let config: PluginConfig = serde_json::from_str(json).unwrap();
1982        assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
1983    }
1984
1985    #[test]
1986    fn plugin_config_cancel_timeout_serde_roundtrip() {
1987        let config = PluginConfig {
1988            ios_cancel_listener_timeout_secs: 3600,
1989            ..Default::default()
1990        };
1991        let json = serde_json::to_string(&config).unwrap();
1992        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1993        assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
1994    }
1995
1996    // --- PluginConfig ios_processing_safety_timeout_secs tests ---
1997
1998    #[test]
1999    fn plugin_config_processing_timeout_default() {
2000        let json = "{}";
2001        let config: PluginConfig = serde_json::from_str(json).unwrap();
2002        assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
2003    }
2004
2005    #[test]
2006    fn plugin_config_processing_timeout_custom() {
2007        let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
2008        let config: PluginConfig = serde_json::from_str(json).unwrap();
2009        assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
2010    }
2011
2012    #[test]
2013    fn plugin_config_processing_timeout_serde_roundtrip() {
2014        let config = PluginConfig {
2015            ios_processing_safety_timeout_secs: 120.0,
2016            ..Default::default()
2017        };
2018        let json = serde_json::to_string(&config).unwrap();
2019        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2020        assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
2021    }
2022
2023    // --- StartKeepaliveArgs tests ---
2024
2025    #[test]
2026    fn start_keepalive_args_with_timeout() {
2027        let args = StartKeepaliveArgs {
2028            label: "Test",
2029            foreground_service_type: "dataSync",
2030            ios_safety_timeout_secs: Some(15.0),
2031            ios_processing_safety_timeout_secs: None,
2032            ios_earliest_refresh_begin_minutes: None,
2033            ios_earliest_processing_begin_minutes: None,
2034            ios_requires_external_power: None,
2035            ios_requires_network_connectivity: None,
2036            ios_processing_ceiling_multiplier: None,
2037        };
2038        let json = serde_json::to_string(&args).unwrap();
2039        assert!(
2040            json.contains("\"iosSafetyTimeoutSecs\":15.0"),
2041            "JSON should contain iosSafetyTimeoutSecs: {json}"
2042        );
2043    }
2044
2045    #[test]
2046    fn start_keepalive_args_without_timeout() {
2047        let args = StartKeepaliveArgs {
2048            label: "Test",
2049            foreground_service_type: "dataSync",
2050            ios_safety_timeout_secs: None,
2051            ios_processing_safety_timeout_secs: None,
2052            ios_earliest_refresh_begin_minutes: None,
2053            ios_earliest_processing_begin_minutes: None,
2054            ios_requires_external_power: None,
2055            ios_requires_network_connectivity: None,
2056            ios_processing_ceiling_multiplier: None,
2057        };
2058        let json = serde_json::to_string(&args).unwrap();
2059        assert!(
2060            !json.contains("iosSafetyTimeoutSecs"),
2061            "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
2062        );
2063    }
2064
2065    #[test]
2066    fn start_keepalive_args_processing_timeout() {
2067        let args = StartKeepaliveArgs {
2068            label: "Test",
2069            foreground_service_type: "dataSync",
2070            ios_safety_timeout_secs: None,
2071            ios_processing_safety_timeout_secs: Some(60.0),
2072            ios_earliest_refresh_begin_minutes: None,
2073            ios_earliest_processing_begin_minutes: None,
2074            ios_requires_external_power: None,
2075            ios_requires_network_connectivity: None,
2076            ios_processing_ceiling_multiplier: None,
2077        };
2078        let json = serde_json::to_string(&args).unwrap();
2079        assert!(
2080            json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
2081            "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
2082        );
2083    }
2084
2085    #[test]
2086    fn start_keepalive_args_no_processing_timeout() {
2087        let args = StartKeepaliveArgs {
2088            label: "Test",
2089            foreground_service_type: "dataSync",
2090            ios_safety_timeout_secs: None,
2091            ios_processing_safety_timeout_secs: None,
2092            ios_earliest_refresh_begin_minutes: None,
2093            ios_earliest_processing_begin_minutes: None,
2094            ios_requires_external_power: None,
2095            ios_requires_network_connectivity: None,
2096            ios_processing_ceiling_multiplier: None,
2097        };
2098        let json = serde_json::to_string(&args).unwrap();
2099        assert!(
2100            !json.contains("iosProcessingSafetyTimeoutSecs"),
2101            "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
2102        );
2103    }
2104
2105    #[test]
2106    fn start_keepalive_args_camel_case_keys() {
2107        let args = StartKeepaliveArgs {
2108            label: "Test",
2109            foreground_service_type: "specialUse",
2110            ios_safety_timeout_secs: None,
2111            ios_processing_safety_timeout_secs: None,
2112            ios_earliest_refresh_begin_minutes: None,
2113            ios_earliest_processing_begin_minutes: None,
2114            ios_requires_external_power: None,
2115            ios_requires_network_connectivity: None,
2116            ios_processing_ceiling_multiplier: None,
2117        };
2118        let json = serde_json::to_string(&args).unwrap();
2119        assert!(json.contains("\"label\""), "label: {json}");
2120        assert!(
2121            json.contains("\"foregroundServiceType\""),
2122            "foregroundServiceType: {json}"
2123        );
2124    }
2125
2126    #[test]
2127    fn start_keepalive_args_scheduling_intervals() {
2128        let args = StartKeepaliveArgs {
2129            label: "Test",
2130            foreground_service_type: "dataSync",
2131            ios_safety_timeout_secs: None,
2132            ios_processing_safety_timeout_secs: None,
2133            ios_earliest_refresh_begin_minutes: Some(30.0),
2134            ios_earliest_processing_begin_minutes: Some(60.0),
2135            ios_requires_external_power: None,
2136            ios_requires_network_connectivity: None,
2137            ios_processing_ceiling_multiplier: None,
2138        };
2139        let json = serde_json::to_string(&args).unwrap();
2140        assert!(
2141            json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
2142            "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
2143        );
2144        assert!(
2145            json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
2146            "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
2147        );
2148    }
2149
2150    #[test]
2151    fn start_keepalive_args_processing_options() {
2152        let args = StartKeepaliveArgs {
2153            label: "Test",
2154            foreground_service_type: "dataSync",
2155            ios_safety_timeout_secs: None,
2156            ios_processing_safety_timeout_secs: None,
2157            ios_earliest_refresh_begin_minutes: None,
2158            ios_earliest_processing_begin_minutes: None,
2159            ios_requires_external_power: Some(true),
2160            ios_requires_network_connectivity: Some(true),
2161            ios_processing_ceiling_multiplier: None,
2162        };
2163        let json = serde_json::to_string(&args).unwrap();
2164        assert!(
2165            json.contains("\"iosRequiresExternalPower\":true"),
2166            "JSON should contain iosRequiresExternalPower: {json}"
2167        );
2168        assert!(
2169            json.contains("\"iosRequiresNetworkConnectivity\":true"),
2170            "JSON should contain iosRequiresNetworkConnectivity: {json}"
2171        );
2172    }
2173
2174    #[test]
2175    fn start_keepalive_args_processing_ceiling_multiplier() {
2176        let args = StartKeepaliveArgs {
2177            label: "Test",
2178            foreground_service_type: "dataSync",
2179            ios_safety_timeout_secs: None,
2180            ios_processing_safety_timeout_secs: None,
2181            ios_earliest_refresh_begin_minutes: None,
2182            ios_earliest_processing_begin_minutes: None,
2183            ios_requires_external_power: None,
2184            ios_requires_network_connectivity: None,
2185            ios_processing_ceiling_multiplier: Some(4.0),
2186        };
2187        let json = serde_json::to_string(&args).unwrap();
2188        assert!(
2189            json.contains("\"iosProcessingCeilingMultiplier\":4.0"),
2190            "JSON should contain iosProcessingCeilingMultiplier: {json}"
2191        );
2192    }
2193
2194    // --- PluginConfig new scheduling fields tests ---
2195
2196    #[test]
2197    fn plugin_config_earliest_refresh_default() {
2198        let json = "{}";
2199        let config: PluginConfig = serde_json::from_str(json).unwrap();
2200        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
2201    }
2202
2203    #[test]
2204    fn plugin_config_earliest_processing_default() {
2205        let json = "{}";
2206        let config: PluginConfig = serde_json::from_str(json).unwrap();
2207        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
2208    }
2209
2210    #[test]
2211    fn plugin_config_requires_external_power_default() {
2212        let json = "{}";
2213        let config: PluginConfig = serde_json::from_str(json).unwrap();
2214        assert!(!config.ios_requires_external_power);
2215    }
2216
2217    #[test]
2218    fn plugin_config_requires_network_connectivity_default() {
2219        let json = "{}";
2220        let config: PluginConfig = serde_json::from_str(json).unwrap();
2221        assert!(!config.ios_requires_network_connectivity);
2222    }
2223
2224    #[test]
2225    fn plugin_config_custom_scheduling_intervals() {
2226        let json =
2227            r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
2228        let config: PluginConfig = serde_json::from_str(json).unwrap();
2229        assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
2230        assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
2231    }
2232
2233    #[test]
2234    fn plugin_config_custom_processing_options() {
2235        let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
2236        let config: PluginConfig = serde_json::from_str(json).unwrap();
2237        assert!(config.ios_requires_external_power);
2238        assert!(config.ios_requires_network_connectivity);
2239    }
2240
2241    // --- PluginConfig ios_processing_ceiling_multiplier tests (D2) ---
2242
2243    #[test]
2244    fn plugin_config_processing_ceiling_multiplier_default() {
2245        let json = "{}";
2246        let config: PluginConfig = serde_json::from_str(json).unwrap();
2247        assert_eq!(config.ios_processing_ceiling_multiplier, 4.0);
2248    }
2249
2250    #[test]
2251    fn plugin_config_processing_ceiling_multiplier_custom() {
2252        let json = r#"{"iosProcessingCeilingMultiplier":6.0}"#;
2253        let config: PluginConfig = serde_json::from_str(json).unwrap();
2254        assert_eq!(config.ios_processing_ceiling_multiplier, 6.0);
2255    }
2256
2257    #[test]
2258    fn plugin_config_processing_ceiling_multiplier_serde_roundtrip() {
2259        let config = PluginConfig {
2260            ios_processing_ceiling_multiplier: 6.0,
2261            ..Default::default()
2262        };
2263        let json = serde_json::to_string(&config).unwrap();
2264        assert!(
2265            json.contains("\"iosProcessingCeilingMultiplier\":6.0"),
2266            "JSON should contain iosProcessingCeilingMultiplier: {json}"
2267        );
2268        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2269        assert_eq!(de.ios_processing_ceiling_multiplier, 6.0);
2270    }
2271
2272    // --- PluginConfig channel_capacity tests ---
2273
2274    #[test]
2275    fn plugin_config_channel_capacity_default() {
2276        let json = "{}";
2277        let config: PluginConfig = serde_json::from_str(json).unwrap();
2278        assert_eq!(config.channel_capacity, 16);
2279    }
2280
2281    #[test]
2282    fn plugin_config_channel_capacity_custom() {
2283        let json = r#"{"channelCapacity":32}"#;
2284        let config: PluginConfig = serde_json::from_str(json).unwrap();
2285        assert_eq!(config.channel_capacity, 32);
2286    }
2287
2288    #[test]
2289    fn plugin_config_channel_capacity_serde_roundtrip() {
2290        let config = PluginConfig {
2291            channel_capacity: 64,
2292            ..Default::default()
2293        };
2294        let json = serde_json::to_string(&config).unwrap();
2295        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2296        assert_eq!(de.channel_capacity, 64);
2297    }
2298
2299    #[test]
2300    fn plugin_config_channel_capacity_json_key_camel_case() {
2301        let config = PluginConfig {
2302            channel_capacity: 32,
2303            ..Default::default()
2304        };
2305        let json = serde_json::to_string(&config).unwrap();
2306        assert!(
2307            json.contains("channelCapacity"),
2308            "JSON should use camelCase: {json}"
2309        );
2310    }
2311
2312    // --- PluginConfig android FGS type fields tests ---
2313
2314    #[test]
2315    fn plugin_config_android_fgs_types_default() {
2316        let json = "{}";
2317        let config: PluginConfig = serde_json::from_str(json).unwrap();
2318        assert_eq!(
2319            config.android_foreground_service_types,
2320            vec!["remoteMessaging"]
2321        );
2322    }
2323
2324    #[test]
2325    fn plugin_config_android_fgs_types_custom() {
2326        let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
2327        let config: PluginConfig = serde_json::from_str(json).unwrap();
2328        assert_eq!(
2329            config.android_foreground_service_types,
2330            vec!["dataSync", "specialUse"]
2331        );
2332    }
2333
2334    #[test]
2335    fn plugin_config_android_fgs_types_serde_roundtrip() {
2336        let config = PluginConfig {
2337            android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
2338            ..Default::default()
2339        };
2340        let json = serde_json::to_string(&config).unwrap();
2341        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2342        assert_eq!(
2343            de.android_foreground_service_types,
2344            vec!["location", "connectedDevice"]
2345        );
2346    }
2347
2348    #[test]
2349    fn plugin_config_android_fgs_types_json_key_camel_case() {
2350        let config = PluginConfig {
2351            android_foreground_service_types: vec!["specialUse".into()],
2352            ..Default::default()
2353        };
2354        let json = serde_json::to_string(&config).unwrap();
2355        assert!(
2356            json.contains("androidForegroundServiceTypes"),
2357            "JSON should use camelCase: {json}"
2358        );
2359    }
2360
2361    #[test]
2362    fn plugin_config_android_validate_default() {
2363        let json = "{}";
2364        let config: PluginConfig = serde_json::from_str(json).unwrap();
2365        assert!(config.android_validate_foreground_service_type);
2366    }
2367
2368    #[test]
2369    fn plugin_config_android_validate_false() {
2370        let json = r#"{"androidValidateForegroundServiceType":false}"#;
2371        let config: PluginConfig = serde_json::from_str(json).unwrap();
2372        assert!(!config.android_validate_foreground_service_type);
2373    }
2374
2375    #[test]
2376    fn plugin_config_android_validate_serde_roundtrip() {
2377        let config = PluginConfig {
2378            android_validate_foreground_service_type: false,
2379            ..Default::default()
2380        };
2381        let json = serde_json::to_string(&config).unwrap();
2382        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2383        assert!(!de.android_validate_foreground_service_type);
2384    }
2385
2386    #[test]
2387    fn plugin_config_android_validate_json_key_camel_case() {
2388        let config = PluginConfig {
2389            android_validate_foreground_service_type: false,
2390            ..Default::default()
2391        };
2392        let json = serde_json::to_string(&config).unwrap();
2393        assert!(
2394            json.contains("androidValidateForegroundServiceType"),
2395            "JSON should use camelCase: {json}"
2396        );
2397    }
2398
2399    // --- PluginConfig Android timeout/notification config tests ---
2400
2401    #[test]
2402    fn plugin_config_android_on_timeout_default() {
2403        let json = "{}";
2404        let config: PluginConfig = serde_json::from_str(json).unwrap();
2405        assert_eq!(config.android_on_timeout, "notifyUser");
2406    }
2407
2408    #[test]
2409    fn plugin_config_android_on_timeout_custom() {
2410        let json = r#"{"androidOnTimeout":"stop"}"#;
2411        let config: PluginConfig = serde_json::from_str(json).unwrap();
2412        assert_eq!(config.android_on_timeout, "stop");
2413    }
2414
2415    #[test]
2416    fn plugin_config_android_on_timeout_schedule_recovery() {
2417        let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
2418        let config: PluginConfig = serde_json::from_str(json).unwrap();
2419        assert_eq!(config.android_on_timeout, "scheduleRecovery");
2420    }
2421
2422    #[test]
2423    fn plugin_config_android_on_timeout_serde_roundtrip() {
2424        let config = PluginConfig {
2425            android_on_timeout: "stop".into(),
2426            ..Default::default()
2427        };
2428        let json = serde_json::to_string(&config).unwrap();
2429        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2430        assert_eq!(de.android_on_timeout, "stop");
2431    }
2432
2433    #[test]
2434    fn plugin_config_android_on_timeout_json_key_camel_case() {
2435        let config = PluginConfig {
2436            android_on_timeout: "notifyUser".into(),
2437            ..Default::default()
2438        };
2439        let json = serde_json::to_string(&config).unwrap();
2440        assert!(
2441            json.contains("androidOnTimeout"),
2442            "JSON should use camelCase: {json}"
2443        );
2444    }
2445
2446    #[test]
2447    fn plugin_config_android_notification_channel_id_default() {
2448        let json = "{}";
2449        let config: PluginConfig = serde_json::from_str(json).unwrap();
2450        assert_eq!(config.android_notification_channel_id, "bg_service");
2451    }
2452
2453    #[test]
2454    fn plugin_config_android_notification_channel_id_custom() {
2455        let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
2456        let config: PluginConfig = serde_json::from_str(json).unwrap();
2457        assert_eq!(config.android_notification_channel_id, "my_channel");
2458    }
2459
2460    #[test]
2461    fn plugin_config_android_notification_channel_id_serde_roundtrip() {
2462        let config = PluginConfig {
2463            android_notification_channel_id: "custom_ch".into(),
2464            ..Default::default()
2465        };
2466        let json = serde_json::to_string(&config).unwrap();
2467        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2468        assert_eq!(de.android_notification_channel_id, "custom_ch");
2469    }
2470
2471    #[test]
2472    fn plugin_config_android_notification_channel_id_json_key_camel_case() {
2473        let config = PluginConfig {
2474            android_notification_channel_id: "test".into(),
2475            ..Default::default()
2476        };
2477        let json = serde_json::to_string(&config).unwrap();
2478        assert!(
2479            json.contains("androidNotificationChannelId"),
2480            "JSON should use camelCase: {json}"
2481        );
2482    }
2483
2484    #[test]
2485    fn plugin_config_android_notification_channel_name_default() {
2486        let json = "{}";
2487        let config: PluginConfig = serde_json::from_str(json).unwrap();
2488        assert_eq!(
2489            config.android_notification_channel_name,
2490            "Background Service"
2491        );
2492    }
2493
2494    #[test]
2495    fn plugin_config_android_notification_channel_name_custom() {
2496        let json = r#"{"androidNotificationChannelName":"My Service"}"#;
2497        let config: PluginConfig = serde_json::from_str(json).unwrap();
2498        assert_eq!(config.android_notification_channel_name, "My Service");
2499    }
2500
2501    #[test]
2502    fn plugin_config_android_notification_channel_name_serde_roundtrip() {
2503        let config = PluginConfig {
2504            android_notification_channel_name: "Sync Service".into(),
2505            ..Default::default()
2506        };
2507        let json = serde_json::to_string(&config).unwrap();
2508        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2509        assert_eq!(de.android_notification_channel_name, "Sync Service");
2510    }
2511
2512    #[test]
2513    fn plugin_config_android_notification_channel_name_json_key_camel_case() {
2514        let config = PluginConfig {
2515            android_notification_channel_name: "Test".into(),
2516            ..Default::default()
2517        };
2518        let json = serde_json::to_string(&config).unwrap();
2519        assert!(
2520            json.contains("androidNotificationChannelName"),
2521            "JSON should use camelCase: {json}"
2522        );
2523    }
2524
2525    #[test]
2526    fn plugin_config_android_notification_id_default() {
2527        let json = "{}";
2528        let config: PluginConfig = serde_json::from_str(json).unwrap();
2529        assert_eq!(config.android_notification_id, 9001);
2530    }
2531
2532    #[test]
2533    fn plugin_config_android_notification_id_custom() {
2534        let json = r#"{"androidNotificationId":1234}"#;
2535        let config: PluginConfig = serde_json::from_str(json).unwrap();
2536        assert_eq!(config.android_notification_id, 1234);
2537    }
2538
2539    #[test]
2540    fn plugin_config_android_notification_id_serde_roundtrip() {
2541        let config = PluginConfig {
2542            android_notification_id: 42,
2543            ..Default::default()
2544        };
2545        let json = serde_json::to_string(&config).unwrap();
2546        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2547        assert_eq!(de.android_notification_id, 42);
2548    }
2549
2550    #[test]
2551    fn plugin_config_android_notification_id_json_key_camel_case() {
2552        let config = PluginConfig {
2553            android_notification_id: 5555,
2554            ..Default::default()
2555        };
2556        let json = serde_json::to_string(&config).unwrap();
2557        assert!(
2558            json.contains("androidNotificationId"),
2559            "JSON should use camelCase: {json}"
2560        );
2561    }
2562
2563    #[test]
2564    fn plugin_config_android_notification_small_icon_default() {
2565        let json = "{}";
2566        let config: PluginConfig = serde_json::from_str(json).unwrap();
2567        assert_eq!(config.android_notification_small_icon, None);
2568    }
2569
2570    #[test]
2571    fn plugin_config_android_notification_small_icon_custom() {
2572        let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
2573        let config: PluginConfig = serde_json::from_str(json).unwrap();
2574        assert_eq!(
2575            config.android_notification_small_icon,
2576            Some("ic_notification".to_string())
2577        );
2578    }
2579
2580    #[test]
2581    fn plugin_config_android_notification_small_icon_serde_roundtrip() {
2582        let config = PluginConfig {
2583            android_notification_small_icon: Some("my_icon".into()),
2584            ..Default::default()
2585        };
2586        let json = serde_json::to_string(&config).unwrap();
2587        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2588        assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
2589    }
2590
2591    #[test]
2592    fn plugin_config_android_notification_small_icon_absent_when_none() {
2593        let config = PluginConfig {
2594            android_notification_small_icon: None,
2595            ..Default::default()
2596        };
2597        let json = serde_json::to_string(&config).unwrap();
2598        assert!(
2599            !json.contains("androidNotificationSmallIcon"),
2600            "should be absent when None: {json}"
2601        );
2602    }
2603
2604    #[test]
2605    fn plugin_config_android_notification_small_icon_json_key_camel_case() {
2606        let config = PluginConfig {
2607            android_notification_small_icon: Some("icon".into()),
2608            ..Default::default()
2609        };
2610        let json = serde_json::to_string(&config).unwrap();
2611        assert!(
2612            json.contains("androidNotificationSmallIcon"),
2613            "JSON should use camelCase: {json}"
2614        );
2615    }
2616
2617    #[test]
2618    fn plugin_config_android_show_stop_action_default() {
2619        let json = "{}";
2620        let config: PluginConfig = serde_json::from_str(json).unwrap();
2621        assert!(config.android_show_stop_action);
2622    }
2623
2624    #[test]
2625    fn plugin_config_android_show_stop_action_false() {
2626        let json = r#"{"androidShowStopAction":false}"#;
2627        let config: PluginConfig = serde_json::from_str(json).unwrap();
2628        assert!(!config.android_show_stop_action);
2629    }
2630
2631    #[test]
2632    fn plugin_config_android_show_stop_action_serde_roundtrip() {
2633        let config = PluginConfig {
2634            android_show_stop_action: false,
2635            ..Default::default()
2636        };
2637        let json = serde_json::to_string(&config).unwrap();
2638        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2639        assert!(!de.android_show_stop_action);
2640    }
2641
2642    #[test]
2643    fn plugin_config_android_show_stop_action_json_key_camel_case() {
2644        let config = PluginConfig {
2645            android_show_stop_action: false,
2646            ..Default::default()
2647        };
2648        let json = serde_json::to_string(&config).unwrap();
2649        assert!(
2650            json.contains("androidShowStopAction"),
2651            "JSON should use camelCase: {json}"
2652        );
2653    }
2654
2655    // ── CORE-03: PluginConfig::validate boundary tests ────────────────────
2656
2657    #[test]
2658    fn core03_default_config_is_valid() {
2659        assert!(PluginConfig::default().validate().is_ok());
2660    }
2661
2662    #[test]
2663    fn core03_channel_capacity_zero_rejected() {
2664        let mut c = PluginConfig::default();
2665        c.channel_capacity = 0;
2666        let msg = format!("{}", c.validate().unwrap_err());
2667        assert!(
2668            msg.contains("channelCapacity") && msg.contains('0'),
2669            "expected channelCapacity diagnostic, got: {msg}"
2670        );
2671    }
2672
2673    #[test]
2674    fn core03_ios_safety_timeout_nonpositive_or_nonfinite_rejected() {
2675        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
2676            let mut c = PluginConfig::default();
2677            c.ios_safety_timeout_secs = bad;
2678            assert!(
2679                format!("{}", c.validate().unwrap_err()).contains("iosSafetyTimeoutSecs"),
2680                "value {bad:?} should be rejected"
2681            );
2682        }
2683    }
2684
2685    #[test]
2686    fn core03_ios_cancel_listener_timeout_zero_rejected() {
2687        let mut c = PluginConfig::default();
2688        c.ios_cancel_listener_timeout_secs = 0;
2689        assert!(c.validate().is_err());
2690    }
2691
2692    #[test]
2693    fn core03_ios_processing_safety_timeout_negative_or_nonfinite_rejected() {
2694        for bad in [-1.0, f64::NAN, f64::INFINITY] {
2695            let mut c = PluginConfig::default();
2696            c.ios_processing_safety_timeout_secs = bad;
2697            assert!(c.validate().is_err(), "{bad:?} should be rejected");
2698        }
2699        // 0.0 = uncapped, allowed.
2700        let mut c = PluginConfig::default();
2701        c.ios_processing_safety_timeout_secs = 0.0;
2702        assert!(c.validate().is_ok());
2703        c.ios_processing_safety_timeout_secs = 120.0;
2704        assert!(c.validate().is_ok());
2705    }
2706
2707    #[test]
2708    fn core03_ios_earliest_begin_negative_or_nonfinite_rejected() {
2709        for bad in [-0.1, f64::NAN, f64::INFINITY] {
2710            let mut c = PluginConfig::default();
2711            c.ios_earliest_refresh_begin_minutes = bad;
2712            assert!(c.validate().is_err(), "refresh {bad:?}");
2713            let mut c = PluginConfig::default();
2714            c.ios_earliest_processing_begin_minutes = bad;
2715            assert!(c.validate().is_err(), "processing {bad:?}");
2716        }
2717        // Zero is allowed (BGTaskScheduler treats it as "as soon as possible").
2718        let mut c = PluginConfig::default();
2719        c.ios_earliest_refresh_begin_minutes = 0.0;
2720        assert!(c.validate().is_ok());
2721    }
2722
2723    #[test]
2724    fn core03_ios_processing_ceiling_multiplier_below_one_rejected() {
2725        for bad in [0.0, 0.99, -1.0, f64::NAN] {
2726            let mut c = PluginConfig::default();
2727            c.ios_processing_ceiling_multiplier = bad;
2728            assert!(c.validate().is_err(), "{bad:?} should be rejected");
2729        }
2730        // 1.0 = no adaptive backoff, allowed.
2731        let mut c = PluginConfig::default();
2732        c.ios_processing_ceiling_multiplier = 1.0;
2733        assert!(c.validate().is_ok());
2734    }
2735
2736    #[test]
2737    fn core03_android_notification_id_outside_i32_range_rejected() {
2738        for bad in [0u32, (i32::MAX as u32) + 1, u32::MAX] {
2739            let mut c = PluginConfig::default();
2740            c.android_notification_id = bad;
2741            assert!(c.validate().is_err(), "id {bad} should be rejected");
2742        }
2743        for ok in [1u32, i32::MAX as u32] {
2744            let mut c = PluginConfig::default();
2745            c.android_notification_id = ok;
2746            assert!(c.validate().is_ok(), "id {ok} should pass");
2747        }
2748    }
2749
2750    #[test]
2751    fn core03_android_notification_channel_id_empty_rejected() {
2752        for bad in ["", "   ", "\t"] {
2753            let mut c = PluginConfig::default();
2754            c.android_notification_channel_id = bad.into();
2755            assert!(c.validate().is_err(), "{bad:?} should be rejected");
2756        }
2757    }
2758
2759    #[test]
2760    fn core03_android_notification_channel_name_empty_rejected() {
2761        let mut c = PluginConfig::default();
2762        c.android_notification_channel_name = "   ".into();
2763        assert!(c.validate().is_err());
2764    }
2765
2766    #[test]
2767    fn core03_android_on_timeout_unknown_policy_rejected() {
2768        for bad in ["", "notify", "recover", "STOP", "schedule"] {
2769            let mut c = PluginConfig::default();
2770            c.android_on_timeout = bad.into();
2771            assert!(c.validate().is_err(), "{bad:?} should be rejected");
2772        }
2773        for ok in ["stop", "notifyUser", "scheduleRecovery"] {
2774            let mut c = PluginConfig::default();
2775            c.android_on_timeout = ok.into();
2776            assert!(c.validate().is_ok(), "{ok:?} should pass");
2777        }
2778    }
2779
2780    #[cfg(feature = "desktop-service")]
2781    #[test]
2782    fn core03_desktop_service_mode_invalid_rejected() {
2783        for bad in ["", "os-service", "OSService", "daemon", "in-process"] {
2784            let mut c = PluginConfig::default();
2785            c.desktop_service_mode = bad.into();
2786            assert!(c.validate().is_err(), "{bad:?} should be rejected");
2787        }
2788        for ok in ["inProcess", "osService"] {
2789            let mut c = PluginConfig::default();
2790            c.desktop_service_mode = ok.into();
2791            assert!(c.validate().is_ok(), "{ok:?} should pass");
2792        }
2793    }
2794
2795    #[cfg(feature = "desktop-service")]
2796    #[test]
2797    fn core03_desktop_service_start_timeout_zero_rejected() {
2798        let mut c = PluginConfig::default();
2799        c.desktop_service_start_timeout_ms = 0;
2800        assert!(c.validate().is_err());
2801    }
2802
2803    // --- PluginConfig android_request_notification_permission_on_load tests ---
2804
2805    #[test]
2806    fn plugin_config_android_request_notification_permission_default() {
2807        let json = "{}";
2808        let config: PluginConfig = serde_json::from_str(json).unwrap();
2809        // NTF-09 (Step 10a): the serde default is now false — the load() startup
2810        // prompt is suppressed by default and requested via a consented path.
2811        assert!(!config.android_request_notification_permission_on_load);
2812    }
2813
2814    #[test]
2815    fn plugin_config_android_request_notification_permission_false() {
2816        let json = r#"{"androidRequestNotificationPermissionOnLoad":false}"#;
2817        let config: PluginConfig = serde_json::from_str(json).unwrap();
2818        assert!(!config.android_request_notification_permission_on_load);
2819    }
2820
2821    #[test]
2822    fn plugin_config_android_request_notification_permission_serde_roundtrip() {
2823        let config = PluginConfig {
2824            android_request_notification_permission_on_load: false,
2825            ..Default::default()
2826        };
2827        let json = serde_json::to_string(&config).unwrap();
2828        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2829        assert!(!de.android_request_notification_permission_on_load);
2830    }
2831    // --- CORE-07: NotificationPermissionStatus non_exhaustive + serde ---
2832
2833    #[test]
2834    fn core07_notification_permission_status_internal_construct() {
2835        // Within the defining crate, struct-literal construction still works
2836        // despite #[non_exhaustive] (the attribute restricts downstream
2837        // crates only). Pin this so future refactors don't break the
2838        // internal call sites in lib.rs.
2839        let s = NotificationPermissionStatus {
2840            status: "granted".to_string(),
2841        };
2842        assert_eq!(s.status, "granted");
2843    }
2844
2845    #[test]
2846    fn core07_notification_permission_status_serde_roundtrip() {
2847        // The wire shape is exactly {"status": String} and stays stable.
2848        for v in ["granted", "denied", "notDetermined"] {
2849            let s = NotificationPermissionStatus {
2850                status: v.to_string(),
2851            };
2852            let json = serde_json::to_string(&s).unwrap();
2853            assert_eq!(json, format!("{{\"status\":\"{v}\"}}"));
2854            let de: NotificationPermissionStatus = serde_json::from_str(&json).unwrap();
2855            assert_eq!(de, s);
2856        }
2857    }
2858
2859    #[test]
2860    fn core07_notification_permission_status_is_non_exhaustive() {
2861        // Source-level contract: the struct declaration carries
2862        // #[non_exhaustive]. We assert via a doc-derived snapshot so a
2863        // future removal trips this test.
2864        let src = include_str!("models.rs");
2865        let needle = concat!(
2866            "#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n",
2867            "#[non_exhaustive]\n",
2868            "pub struct NotificationPermissionStatus"
2869        );
2870        assert!(
2871            src.contains(needle),
2872            "NotificationPermissionStatus must retain #[non_exhaustive]"
2873        );
2874    }
2875
2876    #[test]
2877    fn plugin_config_android_timeout_notification_full_roundtrip() {
2878        let config = PluginConfig {
2879            android_on_timeout: "scheduleRecovery".into(),
2880            android_notification_channel_id: "my_ch".into(),
2881            android_notification_channel_name: "My Channel".into(),
2882            android_notification_id: 42,
2883            android_notification_small_icon: Some("ic_bg".into()),
2884            android_show_stop_action: false,
2885            ..Default::default()
2886        };
2887        let json = serde_json::to_string(&config).unwrap();
2888        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2889        assert_eq!(de.android_on_timeout, "scheduleRecovery");
2890        assert_eq!(de.android_notification_channel_id, "my_ch");
2891        assert_eq!(de.android_notification_channel_name, "My Channel");
2892        assert_eq!(de.android_notification_id, 42);
2893        assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
2894        assert!(!de.android_show_stop_action);
2895    }
2896
2897    // --- PluginConfig D1 lifecycle notification opt-in tests ---
2898
2899    #[test]
2900    fn plugin_config_notify_keys_default_false() {
2901        let json = "{}";
2902        let config: PluginConfig = serde_json::from_str(json).unwrap();
2903        assert!(!config.notify_on_timeout);
2904        assert!(!config.notify_on_recovery);
2905    }
2906
2907    #[test]
2908    fn plugin_config_notify_on_timeout_custom() {
2909        let json = r#"{"notifyOnTimeout":true}"#;
2910        let config: PluginConfig = serde_json::from_str(json).unwrap();
2911        assert!(config.notify_on_timeout);
2912        assert!(!config.notify_on_recovery);
2913    }
2914
2915    #[test]
2916    fn plugin_config_notify_on_recovery_custom() {
2917        let json = r#"{"notifyOnRecovery":true}"#;
2918        let config: PluginConfig = serde_json::from_str(json).unwrap();
2919        assert!(!config.notify_on_timeout);
2920        assert!(config.notify_on_recovery);
2921    }
2922
2923    #[test]
2924    fn plugin_config_notify_keys_serde_roundtrip() {
2925        let config = PluginConfig {
2926            notify_on_timeout: true,
2927            notify_on_recovery: true,
2928            ..Default::default()
2929        };
2930        let json = serde_json::to_string(&config).unwrap();
2931        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2932        assert!(de.notify_on_timeout);
2933        assert!(de.notify_on_recovery);
2934    }
2935
2936    #[test]
2937    fn plugin_config_notify_keys_json_camel_case() {
2938        let config = PluginConfig {
2939            notify_on_timeout: true,
2940            notify_on_recovery: true,
2941            ..Default::default()
2942        };
2943        let json = serde_json::to_string(&config).unwrap();
2944        assert!(
2945            json.contains("notifyOnTimeout") && json.contains("notifyOnRecovery"),
2946            "JSON should use camelCase: {json}"
2947        );
2948    }
2949
2950    // --- PluginConfig desktop fields tests (feature-gated) ---
2951
2952    #[cfg(feature = "desktop-service")]
2953    #[test]
2954    fn plugin_config_desktop_mode_default() {
2955        let json = "{}";
2956        let config: PluginConfig = serde_json::from_str(json).unwrap();
2957        assert_eq!(config.desktop_service_mode, "inProcess");
2958    }
2959
2960    #[cfg(feature = "desktop-service")]
2961    #[test]
2962    fn plugin_config_desktop_mode_custom() {
2963        let json = r#"{"desktopServiceMode":"osService"}"#;
2964        let config: PluginConfig = serde_json::from_str(json).unwrap();
2965        assert_eq!(config.desktop_service_mode, "osService");
2966    }
2967
2968    #[cfg(feature = "desktop-service")]
2969    #[test]
2970    fn plugin_config_desktop_mode_serde_roundtrip() {
2971        let config = PluginConfig {
2972            desktop_service_mode: "osService".into(),
2973            ..Default::default()
2974        };
2975        let json = serde_json::to_string(&config).unwrap();
2976        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2977        assert_eq!(de.desktop_service_mode, "osService");
2978    }
2979
2980    #[cfg(feature = "desktop-service")]
2981    #[test]
2982    fn plugin_config_desktop_label_default() {
2983        let json = "{}";
2984        let config: PluginConfig = serde_json::from_str(json).unwrap();
2985        assert_eq!(config.desktop_service_label, None);
2986    }
2987
2988    #[cfg(feature = "desktop-service")]
2989    #[test]
2990    fn plugin_config_desktop_label_custom() {
2991        let json = r#"{"desktopServiceLabel":"my.svc"}"#;
2992        let config: PluginConfig = serde_json::from_str(json).unwrap();
2993        assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
2994    }
2995
2996    // --- PluginConfig desktop autostart/start-if-missing/timeout tests ---
2997
2998    #[cfg(feature = "desktop-service")]
2999    #[test]
3000    fn plugin_config_desktop_autostart_default() {
3001        let json = "{}";
3002        let config: PluginConfig = serde_json::from_str(json).unwrap();
3003        assert!(!config.desktop_service_autostart);
3004    }
3005
3006    #[cfg(feature = "desktop-service")]
3007    #[test]
3008    fn plugin_config_desktop_autostart_true() {
3009        let json = r#"{"desktopServiceAutostart":true}"#;
3010        let config: PluginConfig = serde_json::from_str(json).unwrap();
3011        assert!(config.desktop_service_autostart);
3012    }
3013
3014    #[cfg(feature = "desktop-service")]
3015    #[test]
3016    fn plugin_config_desktop_autostart_serde_roundtrip() {
3017        let config = PluginConfig {
3018            desktop_service_autostart: true,
3019            ..Default::default()
3020        };
3021        let json = serde_json::to_string(&config).unwrap();
3022        let de: PluginConfig = serde_json::from_str(&json).unwrap();
3023        assert!(de.desktop_service_autostart);
3024    }
3025
3026    #[cfg(feature = "desktop-service")]
3027    #[test]
3028    fn plugin_config_desktop_autostart_json_key_camel_case() {
3029        let config = PluginConfig {
3030            desktop_service_autostart: true,
3031            ..Default::default()
3032        };
3033        let json = serde_json::to_string(&config).unwrap();
3034        assert!(
3035            json.contains("desktopServiceAutostart"),
3036            "JSON should use camelCase: {json}"
3037        );
3038    }
3039
3040    #[cfg(feature = "desktop-service")]
3041    #[test]
3042    fn plugin_config_desktop_start_if_missing_default() {
3043        let json = "{}";
3044        let config: PluginConfig = serde_json::from_str(json).unwrap();
3045        assert!(!config.desktop_start_service_if_missing);
3046    }
3047
3048    #[cfg(feature = "desktop-service")]
3049    #[test]
3050    fn plugin_config_desktop_start_if_missing_true() {
3051        let json = r#"{"desktopStartServiceIfMissing":true}"#;
3052        let config: PluginConfig = serde_json::from_str(json).unwrap();
3053        assert!(config.desktop_start_service_if_missing);
3054    }
3055
3056    #[cfg(feature = "desktop-service")]
3057    #[test]
3058    fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
3059        let config = PluginConfig {
3060            desktop_start_service_if_missing: true,
3061            ..Default::default()
3062        };
3063        let json = serde_json::to_string(&config).unwrap();
3064        let de: PluginConfig = serde_json::from_str(&json).unwrap();
3065        assert!(de.desktop_start_service_if_missing);
3066    }
3067
3068    #[cfg(feature = "desktop-service")]
3069    #[test]
3070    fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
3071        let config = PluginConfig {
3072            desktop_start_service_if_missing: true,
3073            ..Default::default()
3074        };
3075        let json = serde_json::to_string(&config).unwrap();
3076        assert!(
3077            json.contains("desktopStartServiceIfMissing"),
3078            "JSON should use camelCase: {json}"
3079        );
3080    }
3081
3082    /// DESK-01: the Windows-daemon opt-in config field and its serde default
3083    /// are gone for good. Pin the absence so a future revert trips this test.
3084    #[cfg(feature = "desktop-service")]
3085    #[test]
3086    fn desk01_windows_daemon_opt_in_field_is_absent() {
3087        let src = include_str!("models.rs");
3088        // The field name is built by concat so this test's own source does
3089        // not match the literal needle (self-reference guard).
3090        let needle = ["desktop_windows", "_daemon_opt_in"].concat();
3091        // Allow the references inside THIS test function (the source-grep
3092        // contract is about the struct + Default impl, not the test).
3093        let without_this_test = src
3094            .split("fn desk01_windows_daemon_opt_in_field_is_absent")
3095            .next()
3096            .unwrap_or("");
3097        assert!(
3098            !without_this_test.contains(&needle[..]),
3099            "DESK-01: the opt-in field must not be re-introduced on PluginConfig"
3100        );
3101        // The serde key is gone too — unknown keys are accepted by serde's
3102        // default (no deny_unknown_fields), but the field is gone from the
3103        // struct, so this config cannot enable anything.
3104        let json = r#"{"desktopWindowsDaemonOptIn":true}"#;
3105        let config: PluginConfig = serde_json::from_str(json).unwrap();
3106        let _ = config;
3107    }
3108
3109    #[cfg(feature = "desktop-service")]
3110    #[test]
3111    fn plugin_config_desktop_start_timeout_default() {
3112        let json = "{}";
3113        let config: PluginConfig = serde_json::from_str(json).unwrap();
3114        assert_eq!(config.desktop_service_start_timeout_ms, 5000);
3115    }
3116
3117    #[cfg(feature = "desktop-service")]
3118    #[test]
3119    fn plugin_config_desktop_start_timeout_custom() {
3120        let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
3121        let config: PluginConfig = serde_json::from_str(json).unwrap();
3122        assert_eq!(config.desktop_service_start_timeout_ms, 10000);
3123    }
3124
3125    #[cfg(feature = "desktop-service")]
3126    #[test]
3127    fn plugin_config_desktop_start_timeout_serde_roundtrip() {
3128        let config = PluginConfig {
3129            desktop_service_start_timeout_ms: 15000,
3130            ..Default::default()
3131        };
3132        let json = serde_json::to_string(&config).unwrap();
3133        let de: PluginConfig = serde_json::from_str(&json).unwrap();
3134        assert_eq!(de.desktop_service_start_timeout_ms, 15000);
3135    }
3136
3137    #[cfg(feature = "desktop-service")]
3138    #[test]
3139    fn plugin_config_desktop_start_timeout_json_key_camel_case() {
3140        let config = PluginConfig {
3141            desktop_service_start_timeout_ms: 3000,
3142            ..Default::default()
3143        };
3144        let json = serde_json::to_string(&config).unwrap();
3145        assert!(
3146            json.contains("desktopServiceStartTimeoutMs"),
3147            "JSON should use camelCase: {json}"
3148        );
3149    }
3150
3151    #[cfg(feature = "desktop-service")]
3152    #[test]
3153    fn plugin_config_desktop_all_new_fields_roundtrip() {
3154        let config = PluginConfig {
3155            desktop_service_autostart: true,
3156            desktop_start_service_if_missing: true,
3157            desktop_service_start_timeout_ms: 8000,
3158            ..Default::default()
3159        };
3160        let json = serde_json::to_string(&config).unwrap();
3161        let de: PluginConfig = serde_json::from_str(&json).unwrap();
3162        assert!(de.desktop_service_autostart);
3163        assert!(de.desktop_start_service_if_missing);
3164        assert_eq!(de.desktop_service_start_timeout_ms, 8000);
3165    }
3166
3167    use tauri::AppHandle;
3168
3169    // --- ServiceContext mobile fields tests ---
3170
3171    /// Compile-time + runtime test: ServiceContext mobile fields are String.
3172    #[cfg(mobile)]
3173    #[allow(dead_code)]
3174    fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
3175        let ctx = ServiceContext {
3176            notifier: Notifier { app: app.clone() },
3177            app,
3178            shutdown: CancellationToken::new(),
3179            service_label: "Syncing".into(),
3180            foreground_service_type: "dataSync".into(),
3181        };
3182        assert_eq!(ctx.service_label, "Syncing");
3183        assert_eq!(ctx.foreground_service_type, "dataSync");
3184    }
3185
3186    /// Compile-time + runtime test: ServiceContext on desktop has no mobile fields.
3187    #[cfg(not(mobile))]
3188    #[allow(dead_code)]
3189    fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
3190        let ctx = ServiceContext {
3191            notifier: Notifier { app: app.clone() },
3192            app,
3193            shutdown: CancellationToken::new(),
3194        };
3195        // Compiles — service_label and foreground_service_type are absent.
3196        let _ = ctx;
3197    }
3198
3199    // --- Foreground service type validation tests ---
3200
3201    #[test]
3202    fn validate_data_sync_passes() {
3203        assert!(
3204            validate_foreground_service_type("dataSync").is_ok(),
3205            "dataSync should be valid"
3206        );
3207    }
3208
3209    #[test]
3210    fn validate_special_use_passes() {
3211        assert!(
3212            validate_foreground_service_type("specialUse").is_ok(),
3213            "specialUse should be valid"
3214        );
3215    }
3216
3217    #[test]
3218    fn validate_invalid_type_returns_platform_error() {
3219        let result = validate_foreground_service_type("invalidType");
3220        assert!(result.is_err(), "invalidType should be rejected");
3221        match result {
3222            Err(crate::error::ServiceError::Platform(msg)) => {
3223                assert!(
3224                    msg.contains("invalidType"),
3225                    "error should mention the type: {msg}"
3226                );
3227            }
3228            other => panic!("Expected Platform error, got: {other:?}"),
3229        }
3230    }
3231
3232    #[test]
3233    fn validate_all_14_types_pass() {
3234        for &t in VALID_FOREGROUND_SERVICE_TYPES {
3235            assert!(
3236                validate_foreground_service_type(t).is_ok(),
3237                "{t} should be valid"
3238            );
3239        }
3240    }
3241
3242    #[test]
3243    fn valid_types_count_is_14() {
3244        assert_eq!(
3245            VALID_FOREGROUND_SERVICE_TYPES.len(),
3246            14,
3247            "should have exactly 14 valid types"
3248        );
3249    }
3250
3251    #[test]
3252    fn validate_empty_string_returns_error() {
3253        let result = validate_foreground_service_type("");
3254        assert!(result.is_err(), "empty string should be rejected");
3255    }
3256
3257    #[test]
3258    fn validate_case_sensitive() {
3259        // "DataSync" (capitalized) should NOT pass — case-sensitive.
3260        let result = validate_foreground_service_type("DataSync");
3261        assert!(
3262            result.is_err(),
3263            "validation should be case-sensitive: DataSync should fail"
3264        );
3265    }
3266
3267    // --- Foreground service type allowlist validation tests ---
3268
3269    #[test]
3270    fn allowlist_accepted_type_in_list() {
3271        let allowlist = vec!["remoteMessaging".to_string()];
3272        let result = validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true);
3273        assert!(result.is_ok(), "type in allowlist should be accepted");
3274    }
3275
3276    #[test]
3277    fn allowlist_rejected_type_not_in_list() {
3278        let allowlist = vec!["remoteMessaging".to_string()];
3279        let result = validate_fg_type_against_allowlist("specialUse", &allowlist, true);
3280        assert!(result.is_err(), "type not in allowlist should be rejected");
3281        match result {
3282            Err(ServiceError::Platform(msg)) => {
3283                assert!(
3284                    msg.contains("specialUse"),
3285                    "error should mention the type: {msg}"
3286                );
3287                assert!(
3288                    msg.contains("not allowed"),
3289                    "error should say not allowed: {msg}"
3290                );
3291            }
3292            other => panic!("Expected Platform error, got: {other:?}"),
3293        }
3294    }
3295
3296    #[test]
3297    fn allowlist_empty_type_rejected() {
3298        let allowlist = vec!["dataSync".to_string()];
3299        let result = validate_fg_type_against_allowlist("", &allowlist, true);
3300        assert!(result.is_err(), "empty type should be rejected");
3301        match result {
3302            Err(ServiceError::Platform(msg)) => {
3303                assert!(
3304                    msg.contains("must not be empty"),
3305                    "error should mention empty: {msg}"
3306                );
3307            }
3308            other => panic!("Expected Platform error, got: {other:?}"),
3309        }
3310    }
3311
3312    #[test]
3313    fn allowlist_case_insensitive_match() {
3314        let allowlist = vec!["remoteMessaging".to_string()];
3315        let result = validate_fg_type_against_allowlist("RemoteMessaging", &allowlist, true);
3316        assert!(result.is_ok(), "case-insensitive match should be accepted");
3317    }
3318
3319    #[test]
3320    fn allowlist_validation_skipped_when_disabled() {
3321        let allowlist = vec!["dataSync".to_string()];
3322        let result = validate_fg_type_against_allowlist("specialUse", &allowlist, false);
3323        assert!(result.is_ok(), "validation disabled should accept any type");
3324    }
3325
3326    #[test]
3327    fn allowlist_multiple_types() {
3328        let allowlist = vec![
3329            "dataSync".to_string(),
3330            "remoteMessaging".to_string(),
3331            "specialUse".to_string(),
3332        ];
3333        assert!(
3334            validate_fg_type_against_allowlist("dataSync", &allowlist, true).is_ok(),
3335            "dataSync should be in allowlist"
3336        );
3337        assert!(
3338            validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true).is_ok(),
3339            "remoteMessaging should be in allowlist"
3340        );
3341        assert!(
3342            validate_fg_type_against_allowlist("specialUse", &allowlist, true).is_ok(),
3343            "specialUse should be in allowlist"
3344        );
3345        assert!(
3346            validate_fg_type_against_allowlist("camera", &allowlist, true).is_err(),
3347            "camera should NOT be in allowlist"
3348        );
3349    }
3350
3351    // --- ServiceState serde tests ---
3352
3353    #[test]
3354    fn service_state_idle_serde_roundtrip() {
3355        let state = ServiceState::Idle;
3356        let json = serde_json::to_string(&state).unwrap();
3357        let de: ServiceState = serde_json::from_str(&json).unwrap();
3358        assert_eq!(de, ServiceState::Idle);
3359    }
3360
3361    #[test]
3362    fn service_state_initializing_serde_roundtrip() {
3363        let state = ServiceState::Initializing;
3364        let json = serde_json::to_string(&state).unwrap();
3365        let de: ServiceState = serde_json::from_str(&json).unwrap();
3366        assert_eq!(de, ServiceState::Initializing);
3367    }
3368
3369    #[test]
3370    fn service_state_running_serde_roundtrip() {
3371        let state = ServiceState::Running;
3372        let json = serde_json::to_string(&state).unwrap();
3373        let de: ServiceState = serde_json::from_str(&json).unwrap();
3374        assert_eq!(de, ServiceState::Running);
3375    }
3376
3377    #[test]
3378    fn service_state_stopped_serde_roundtrip() {
3379        let state = ServiceState::Stopped;
3380        let json = serde_json::to_string(&state).unwrap();
3381        let de: ServiceState = serde_json::from_str(&json).unwrap();
3382        assert_eq!(de, ServiceState::Stopped);
3383    }
3384
3385    #[test]
3386    fn service_state_json_values_are_camel_case() {
3387        assert_eq!(
3388            serde_json::to_string(&ServiceState::Idle).unwrap(),
3389            "\"idle\""
3390        );
3391        assert_eq!(
3392            serde_json::to_string(&ServiceState::Initializing).unwrap(),
3393            "\"initializing\""
3394        );
3395        assert_eq!(
3396            serde_json::to_string(&ServiceState::Running).unwrap(),
3397            "\"running\""
3398        );
3399        assert_eq!(
3400            serde_json::to_string(&ServiceState::Stopped).unwrap(),
3401            "\"stopped\""
3402        );
3403    }
3404
3405    // --- ServiceStatus serde tests ---
3406
3407    #[test]
3408    fn service_status_serde_roundtrip_idle() {
3409        let status = ServiceStatus {
3410            state: ServiceState::Idle,
3411            ..Default::default()
3412        };
3413        let json = serde_json::to_string(&status).unwrap();
3414        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3415        assert_eq!(de.state, ServiceState::Idle);
3416        assert_eq!(de.last_error, None);
3417    }
3418
3419    #[test]
3420    fn service_status_serde_roundtrip_with_error() {
3421        let status = ServiceStatus {
3422            state: ServiceState::Stopped,
3423            last_error: Some("init failed".into()),
3424            ..Default::default()
3425        };
3426        let json = serde_json::to_string(&status).unwrap();
3427        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3428        assert_eq!(de.state, ServiceState::Stopped);
3429        assert_eq!(de.last_error, Some("init failed".into()));
3430    }
3431
3432    #[test]
3433    fn service_status_json_keys_camel_case() {
3434        let status = ServiceStatus {
3435            state: ServiceState::Running,
3436            ..Default::default()
3437        };
3438        let json = serde_json::to_string(&status).unwrap();
3439        assert!(json.contains("\"state\":"), "state key: {json}");
3440        assert!(json.contains("\"lastError\":"), "lastError key: {json}");
3441    }
3442
3443    #[test]
3444    fn service_status_json_null_last_error() {
3445        let status = ServiceStatus {
3446            state: ServiceState::Idle,
3447            ..Default::default()
3448        };
3449        let json = serde_json::to_string(&status).unwrap();
3450        assert!(
3451            json.contains("\"lastError\":null"),
3452            "lastError should be null: {json}"
3453        );
3454    }
3455
3456    // --- Platform tests ---
3457
3458    #[test]
3459    fn platform_serde_roundtrip() {
3460        for variant in [
3461            Platform::Android,
3462            Platform::Ios,
3463            Platform::Windows,
3464            Platform::Macos,
3465            Platform::Linux,
3466            Platform::Unknown,
3467        ] {
3468            let json = serde_json::to_string(&variant).unwrap();
3469            let de: Platform = serde_json::from_str(&json).unwrap();
3470            assert_eq!(de, variant);
3471        }
3472    }
3473
3474    #[test]
3475    fn platform_json_values_are_camel_case() {
3476        assert_eq!(
3477            serde_json::to_string(&Platform::Android).unwrap(),
3478            "\"android\""
3479        );
3480        assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
3481        assert_eq!(
3482            serde_json::to_string(&Platform::Windows).unwrap(),
3483            "\"windows\""
3484        );
3485        assert_eq!(
3486            serde_json::to_string(&Platform::Macos).unwrap(),
3487            "\"macos\""
3488        );
3489        assert_eq!(
3490            serde_json::to_string(&Platform::Linux).unwrap(),
3491            "\"linux\""
3492        );
3493        assert_eq!(
3494            serde_json::to_string(&Platform::Unknown).unwrap(),
3495            "\"unknown\""
3496        );
3497    }
3498
3499    // --- LifecycleMode tests ---
3500
3501    #[test]
3502    fn lifecycle_mode_serde_roundtrip() {
3503        for variant in [
3504            LifecycleMode::AndroidForegroundService,
3505            LifecycleMode::IosBgTaskScheduler,
3506            LifecycleMode::DesktopInProcess,
3507            LifecycleMode::DesktopOsService,
3508        ] {
3509            let json = serde_json::to_string(&variant).unwrap();
3510            let de: LifecycleMode = serde_json::from_str(&json).unwrap();
3511            assert_eq!(de, variant);
3512        }
3513    }
3514
3515    #[test]
3516    fn lifecycle_mode_json_values_are_camel_case() {
3517        assert_eq!(
3518            serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
3519            "\"androidForegroundService\""
3520        );
3521        assert_eq!(
3522            serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
3523            "\"iosBgTaskScheduler\""
3524        );
3525        assert_eq!(
3526            serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
3527            "\"desktopInProcess\""
3528        );
3529        assert_eq!(
3530            serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
3531            "\"desktopOsService\""
3532        );
3533    }
3534
3535    // --- LifecycleGuarantee tests ---
3536
3537    #[test]
3538    fn lifecycle_guarantee_serde_roundtrip() {
3539        for variant in [
3540            LifecycleGuarantee::Guaranteed,
3541            LifecycleGuarantee::BestEffort,
3542            LifecycleGuarantee::Unsupported,
3543        ] {
3544            let json = serde_json::to_string(&variant).unwrap();
3545            let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
3546            assert_eq!(de, variant);
3547        }
3548    }
3549
3550    #[test]
3551    fn lifecycle_guarantee_json_values_are_camel_case() {
3552        assert_eq!(
3553            serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
3554            "\"guaranteed\""
3555        );
3556        assert_eq!(
3557            serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
3558            "\"bestEffort\""
3559        );
3560        assert_eq!(
3561            serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
3562            "\"unsupported\""
3563        );
3564    }
3565
3566    // --- PlatformCapabilities tests ---
3567
3568    #[test]
3569    fn platform_capabilities_serde_roundtrip() {
3570        let caps = PlatformCapabilities {
3571            platform: Platform::Android,
3572            lifecycle_mode: LifecycleMode::AndroidForegroundService,
3573            survives_app_close: LifecycleGuarantee::BestEffort,
3574            survives_reboot: LifecycleGuarantee::BestEffort,
3575            survives_force_quit: LifecycleGuarantee::Unsupported,
3576            background_execution: LifecycleGuarantee::Guaranteed,
3577            limitations: vec!["OEM battery optimization".into()],
3578            required_setup: vec!["FOREGROUND_SERVICE permission".into()],
3579        };
3580        let json = serde_json::to_string(&caps).unwrap();
3581        let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
3582        assert_eq!(de, caps);
3583    }
3584
3585    #[test]
3586    fn platform_capabilities_json_keys_camel_case() {
3587        let caps = PlatformCapabilities {
3588            platform: Platform::Linux,
3589            lifecycle_mode: LifecycleMode::DesktopInProcess,
3590            survives_app_close: LifecycleGuarantee::Unsupported,
3591            survives_reboot: LifecycleGuarantee::Unsupported,
3592            survives_force_quit: LifecycleGuarantee::Unsupported,
3593            background_execution: LifecycleGuarantee::Guaranteed,
3594            limitations: vec![],
3595            required_setup: vec![],
3596        };
3597        let json = serde_json::to_string(&caps).unwrap();
3598        assert!(json.contains("\"platform\":"), "platform: {json}");
3599        assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
3600        assert!(
3601            json.contains("\"survivesAppClose\":"),
3602            "survivesAppClose: {json}"
3603        );
3604        assert!(
3605            json.contains("\"survivesReboot\":"),
3606            "survivesReboot: {json}"
3607        );
3608        assert!(
3609            json.contains("\"survivesForceQuit\":"),
3610            "survivesForceQuit: {json}"
3611        );
3612        assert!(
3613            json.contains("\"backgroundExecution\":"),
3614            "backgroundExecution: {json}"
3615        );
3616        assert!(json.contains("\"limitations\":"), "limitations: {json}");
3617        assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
3618    }
3619
3620    #[test]
3621    fn platform_capabilities_empty_collections_serialize() {
3622        let caps = PlatformCapabilities {
3623            platform: Platform::Unknown,
3624            lifecycle_mode: LifecycleMode::DesktopInProcess,
3625            survives_app_close: LifecycleGuarantee::Unsupported,
3626            survives_reboot: LifecycleGuarantee::Unsupported,
3627            survives_force_quit: LifecycleGuarantee::Unsupported,
3628            background_execution: LifecycleGuarantee::Unsupported,
3629            limitations: vec![],
3630            required_setup: vec![],
3631        };
3632        let json = serde_json::to_string(&caps).unwrap();
3633        assert!(json.contains("\"limitations\":[]"), "{json}");
3634        assert!(json.contains("\"requiredSetup\":[]"), "{json}");
3635    }
3636
3637    // --- NativeState tests ---
3638
3639    #[test]
3640    fn native_state_serde_roundtrip() {
3641        for variant in [
3642            NativeState::Idle,
3643            NativeState::Starting,
3644            NativeState::Running,
3645            NativeState::Stopping,
3646            NativeState::Timeout,
3647            NativeState::Expired,
3648            NativeState::Recovering,
3649            NativeState::Error,
3650        ] {
3651            let json = serde_json::to_string(&variant).unwrap();
3652            let de: NativeState = serde_json::from_str(&json).unwrap();
3653            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3654        }
3655    }
3656
3657    #[test]
3658    fn native_state_json_values_are_camel_case() {
3659        assert_eq!(
3660            serde_json::to_string(&NativeState::Idle).unwrap(),
3661            "\"idle\""
3662        );
3663        assert_eq!(
3664            serde_json::to_string(&NativeState::Starting).unwrap(),
3665            "\"starting\""
3666        );
3667        assert_eq!(
3668            serde_json::to_string(&NativeState::Running).unwrap(),
3669            "\"running\""
3670        );
3671        assert_eq!(
3672            serde_json::to_string(&NativeState::Stopping).unwrap(),
3673            "\"stopping\""
3674        );
3675        assert_eq!(
3676            serde_json::to_string(&NativeState::Timeout).unwrap(),
3677            "\"timeout\""
3678        );
3679        assert_eq!(
3680            serde_json::to_string(&NativeState::Expired).unwrap(),
3681            "\"expired\""
3682        );
3683        assert_eq!(
3684            serde_json::to_string(&NativeState::Recovering).unwrap(),
3685            "\"recovering\""
3686        );
3687        assert_eq!(
3688            serde_json::to_string(&NativeState::Error).unwrap(),
3689            "\"error\""
3690        );
3691    }
3692
3693    // --- Extended ServiceStatus tests ---
3694
3695    #[test]
3696    fn service_status_backward_compat_deserialize_old_json() {
3697        let old_json = r#"{"state":"running","lastError":null}"#;
3698        let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
3699        assert_eq!(status.state, ServiceState::Running);
3700        assert_eq!(status.last_error, None);
3701        assert_eq!(status.desired_running, None);
3702        assert_eq!(status.native_state, None);
3703        assert_eq!(status.platform_mode, None);
3704        assert_eq!(status.last_start_config, None);
3705        assert_eq!(status.last_heartbeat_at, None);
3706        assert_eq!(status.restart_attempt, None);
3707        assert_eq!(status.recovery_reason, None);
3708        assert_eq!(status.platform_error, None);
3709    }
3710
3711    #[test]
3712    fn service_status_new_fields_serialize_when_present() {
3713        let status = ServiceStatus {
3714            state: ServiceState::Running,
3715            last_error: None,
3716            desired_running: Some(true),
3717            native_state: Some(NativeState::Running),
3718            platform_mode: Some(LifecycleMode::AndroidForegroundService),
3719            last_start_config: Some(StartConfig::default()),
3720            last_heartbeat_at: Some(1234567890),
3721            restart_attempt: Some(2),
3722            recovery_reason: Some("boot recovery".into()),
3723            platform_error: Some("timeout exceeded".into()),
3724        };
3725        let json = serde_json::to_string(&status).unwrap();
3726        assert!(json.contains("\"desiredRunning\":true"), "{json}");
3727        assert!(json.contains("\"nativeState\":\"running\""), "{json}");
3728        assert!(
3729            json.contains("\"platformMode\":\"androidForegroundService\""),
3730            "{json}"
3731        );
3732        assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
3733        assert!(json.contains("\"restartAttempt\":2"), "{json}");
3734        assert!(
3735            json.contains("\"recoveryReason\":\"boot recovery\""),
3736            "{json}"
3737        );
3738        assert!(
3739            json.contains("\"platformError\":\"timeout exceeded\""),
3740            "{json}"
3741        );
3742    }
3743
3744    #[test]
3745    fn service_status_new_fields_absent_when_none() {
3746        let status = ServiceStatus {
3747            state: ServiceState::Idle,
3748            last_error: None,
3749            desired_running: None,
3750            native_state: None,
3751            platform_mode: None,
3752            last_start_config: None,
3753            last_heartbeat_at: None,
3754            restart_attempt: None,
3755            recovery_reason: None,
3756            platform_error: None,
3757        };
3758        let json = serde_json::to_string(&status).unwrap();
3759        assert!(!json.contains("desiredRunning"), "should be absent: {json}");
3760        assert!(!json.contains("nativeState"), "should be absent: {json}");
3761        assert!(!json.contains("platformMode"), "should be absent: {json}");
3762        assert!(
3763            !json.contains("lastStartConfig"),
3764            "should be absent: {json}"
3765        );
3766        assert!(
3767            !json.contains("lastHeartbeatAt"),
3768            "should be absent: {json}"
3769        );
3770        assert!(!json.contains("restartAttempt"), "should be absent: {json}");
3771        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
3772        assert!(!json.contains("platformError"), "should be absent: {json}");
3773    }
3774
3775    #[test]
3776    fn service_status_default_impl() {
3777        let status = ServiceStatus::default();
3778        assert_eq!(status.state, ServiceState::Idle);
3779        assert_eq!(status.last_error, None);
3780        assert_eq!(status.desired_running, None);
3781        assert_eq!(status.native_state, None);
3782        assert_eq!(status.platform_mode, None);
3783        assert_eq!(status.last_start_config, None);
3784        assert_eq!(status.last_heartbeat_at, None);
3785        assert_eq!(status.restart_attempt, None);
3786        assert_eq!(status.recovery_reason, None);
3787        assert_eq!(status.platform_error, None);
3788    }
3789
3790    #[test]
3791    fn service_status_full_roundtrip_with_all_fields() {
3792        let status = ServiceStatus {
3793            state: ServiceState::Running,
3794            last_error: Some("previous crash".into()),
3795            desired_running: Some(true),
3796            native_state: Some(NativeState::Recovering),
3797            platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
3798            last_start_config: Some(StartConfig {
3799                service_label: "Sync".into(),
3800                foreground_service_type: "dataSync".into(),
3801            }),
3802            last_heartbeat_at: Some(999),
3803            restart_attempt: Some(3),
3804            recovery_reason: Some("force stop".into()),
3805            platform_error: Some("scheduler busy".into()),
3806        };
3807        let json = serde_json::to_string(&status).unwrap();
3808        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3809        assert_eq!(de.state, ServiceState::Running);
3810        assert_eq!(de.last_error, Some("previous crash".into()));
3811        assert_eq!(de.desired_running, Some(true));
3812        assert_eq!(de.native_state, Some(NativeState::Recovering));
3813        assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
3814        assert!(de.last_start_config.is_some());
3815        assert_eq!(de.last_heartbeat_at, Some(999));
3816        assert_eq!(de.restart_attempt, Some(3));
3817        assert_eq!(de.recovery_reason, Some("force stop".into()));
3818        assert_eq!(de.platform_error, Some("scheduler busy".into()));
3819    }
3820
3821    #[test]
3822    fn platform_capabilities_deserialize_from_json() {
3823        let json = r#"{
3824            "platform":"ios",
3825            "lifecycleMode":"iosBgTaskScheduler",
3826            "survivesAppClose":"bestEffort",
3827            "survivesReboot":"bestEffort",
3828            "survivesForceQuit":"unsupported",
3829            "backgroundExecution":"bestEffort",
3830            "limitations":["Cannot guarantee continuous execution"],
3831            "requiredSetup":["UIBackgroundModes in Info.plist"]
3832        }"#;
3833        let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
3834        assert_eq!(caps.platform, Platform::Ios);
3835        assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
3836        assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
3837        assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
3838        assert_eq!(caps.limitations.len(), 1);
3839        assert_eq!(caps.required_setup.len(), 1);
3840    }
3841
3842    // --- IOSSchedulingStatus tests ---
3843
3844    #[test]
3845    fn ios_scheduling_status_both_scheduled() {
3846        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3847        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3848        assert!(status.refresh_scheduled);
3849        assert!(status.processing_scheduled);
3850        assert_eq!(status.refresh_error, None);
3851        assert_eq!(status.processing_error, None);
3852    }
3853
3854    #[test]
3855    fn ios_scheduling_status_partial_success() {
3856        let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
3857        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3858        assert!(status.refresh_scheduled);
3859        assert!(!status.processing_scheduled);
3860        assert_eq!(status.refresh_error, None);
3861        assert_eq!(status.processing_error, Some("not permitted".to_string()));
3862    }
3863
3864    #[test]
3865    fn ios_scheduling_status_with_errors() {
3866        let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
3867        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3868        assert!(!status.refresh_scheduled);
3869        assert!(!status.processing_scheduled);
3870        assert_eq!(status.refresh_error, Some("err1".to_string()));
3871        assert_eq!(status.processing_error, Some("err2".to_string()));
3872    }
3873
3874    #[test]
3875    fn ios_scheduling_status_serde_roundtrip() {
3876        let status = IOSSchedulingStatus {
3877            refresh_scheduled: true,
3878            processing_scheduled: false,
3879            refresh_error: None,
3880            processing_error: Some("busy".into()),
3881        };
3882        let json = serde_json::to_string(&status).unwrap();
3883        let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
3884        assert_eq!(de, status);
3885    }
3886
3887    #[test]
3888    fn ios_scheduling_status_json_keys_camel_case() {
3889        let status = IOSSchedulingStatus {
3890            refresh_scheduled: true,
3891            processing_scheduled: true,
3892            refresh_error: Some("err".into()),
3893            processing_error: None,
3894        };
3895        let json = serde_json::to_string(&status).unwrap();
3896        assert!(json.contains("\"refreshScheduled\":"), "{json}");
3897        assert!(json.contains("\"processingScheduled\":"), "{json}");
3898        assert!(json.contains("\"refreshError\":"), "{json}");
3899        assert!(
3900            !json.contains("processingError"),
3901            "None fields should be absent: {json}"
3902        );
3903    }
3904
3905    #[test]
3906    fn ios_scheduling_status_from_value_null_errors() {
3907        // Simulates the Swift response where errors are NSNull()
3908        let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3909        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3910        assert!(status.refresh_scheduled);
3911        assert!(status.processing_scheduled);
3912        assert_eq!(status.refresh_error, None);
3913        assert_eq!(status.processing_error, None);
3914    }
3915
3916    #[test]
3917    fn ios_scheduling_status_from_value_missing_errors() {
3918        // Swift may not include error fields when scheduling succeeds
3919        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3920        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3921        assert!(status.refresh_scheduled);
3922        assert!(status.processing_scheduled);
3923        assert_eq!(status.refresh_error, None);
3924        assert_eq!(status.processing_error, None);
3925    }
3926
3927    // --- C1/M9: exact-Swift-payload drift-guard fixtures ---
3928    //
3929    // The iOS native layer splits its status query into two `@objc` calls:
3930    //   * `getSchedulingStatus`   -> submit-result shape -> `IOSSchedulingStatus`
3931    //   * `getDesiredStateStatus` -> persisted shape     -> `IOSDesiredStateStatus`
3932    //
3933    // These fixtures commit the *exact* JSON those Swift handlers resolve (NSNull
3934    // serializes to JSON `null`, `TimeInterval` to a JSON float) and prove it
3935    // `from_value`s into the typed DTOs. They live here, not in `mobile.rs`,
3936    // because `mobile.rs` is `#[cfg(mobile)]` and is excluded from the canonical
3937    // macOS gate (`cargo test -p tauri-plugin-background-service --lib`); the
3938    // contract being pinned is the serde shape, which is defined in this file.
3939
3940    /// The exact payload `BackgroundServicePlugin.getSchedulingStatus` resolves
3941    /// after a successful `startKeepalive` (both task types scheduled, no errors).
3942    const SWIFT_SCHEDULING_STATUS_PAYLOAD: &str = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3943
3944    /// The exact payload `BackgroundServicePlugin.getDesiredStateStatus` resolves
3945    /// while the service is desired-running with one refresh task started but not
3946    /// yet completed. `lastScheduleError`/`lastTaskCompletedAt`/
3947    /// `lastCompletionReason` are `NSNull()` (no run has completed yet).
3948    const SWIFT_DESIRED_STATE_PAYLOAD: &str = r#"{"desiredRunning":true,"lastStartConfig":"{\"label\":\"App\"}","lastScheduleError":null,"lastTaskKind":"refresh","lastTaskStartedAt":1719500000.5,"lastTaskCompletedAt":null,"lastCompletionReason":null,"notificationGranted":null}"#;
3949
3950    #[test]
3951    fn ios_scheduling_status_parses_exact_swift_payload() {
3952        let status: IOSSchedulingStatus =
3953            serde_json::from_value(serde_json::from_str(SWIFT_SCHEDULING_STATUS_PAYLOAD).unwrap())
3954                .expect("the exact Swift getSchedulingStatus payload must deserialize");
3955        assert!(status.refresh_scheduled);
3956        assert!(status.processing_scheduled);
3957        assert_eq!(status.refresh_error, None);
3958        assert_eq!(status.processing_error, None);
3959    }
3960
3961    #[test]
3962    fn ios_desired_state_status_parses_exact_swift_payload() {
3963        let status: IOSDesiredStateStatus =
3964            serde_json::from_value(serde_json::from_str(SWIFT_DESIRED_STATE_PAYLOAD).unwrap())
3965                .expect("the exact Swift getDesiredStateStatus payload must deserialize");
3966        assert!(status.desired_running);
3967        assert_eq!(
3968            status.last_start_config.as_deref(),
3969            Some("{\"label\":\"App\"}")
3970        );
3971        assert_eq!(status.last_schedule_error, None);
3972        assert_eq!(status.last_task_kind.as_deref(), Some("refresh"));
3973        assert_eq!(status.last_task_started_at, Some(1719500000.5));
3974        assert_eq!(status.last_task_completed_at, None);
3975        assert_eq!(status.last_completion_reason, None);
3976        assert_eq!(status.notification_granted, None);
3977    }
3978
3979    #[test]
3980    fn ios_desired_state_status_defaults_when_never_started() {
3981        // Fresh install: Swift resolves desiredRunning=false with every optional
3982        // field NSNull(). Required `desired_running` is present; the rest default.
3983        let json = r#"{"desiredRunning":false,"lastStartConfig":null,"lastScheduleError":null,"lastTaskKind":null,"lastTaskStartedAt":null,"lastTaskCompletedAt":null}"#;
3984        let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3985        assert!(!status.desired_running);
3986        assert_eq!(status.last_start_config, None);
3987        assert_eq!(status.last_task_started_at, None);
3988    }
3989
3990    #[test]
3991    fn ios_desired_state_status_camel_case_roundtrip() {
3992        let status = IOSDesiredStateStatus {
3993            desired_running: true,
3994            last_start_config: Some("{}".into()),
3995            last_task_kind: Some("processing".into()),
3996            last_task_started_at: Some(1.0),
3997            last_task_completed_at: None,
3998            last_schedule_error: Some("boom".into()),
3999            last_completion_reason: Some("expired".into()),
4000            notification_granted: Some(true),
4001        };
4002        let json = serde_json::to_string(&status).unwrap();
4003        assert!(json.contains("\"desiredRunning\":"), "{json}");
4004        assert!(json.contains("\"lastStartConfig\":"), "{json}");
4005        assert!(json.contains("\"lastTaskKind\":"), "{json}");
4006        assert!(json.contains("\"lastScheduleError\":"), "{json}");
4007        assert!(json.contains("\"lastCompletionReason\":"), "{json}");
4008        assert!(json.contains("\"notificationGranted\":"), "{json}");
4009        let de: IOSDesiredStateStatus = serde_json::from_str(&json).unwrap();
4010        assert_eq!(de, status);
4011    }
4012
4013    /// M7 (req 3): the persisted desired-state DTO carries the durable
4014    /// last-completion reason the iOS native layer now resolves under
4015    /// `lastCompletionReason`. Absent (legacy) payloads default it to `None`.
4016    #[test]
4017    fn ios_desired_state_status_carries_last_completion_reason() {
4018        let json = r#"{"desiredRunning":true,"lastCompletionReason":"completed"}"#;
4019        let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
4020        assert_eq!(status.last_completion_reason.as_deref(), Some("completed"));
4021
4022        // Legacy payload without the key defaults to None (forward-compatible).
4023        let legacy = r#"{"desiredRunning":false}"#;
4024        let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
4025        assert_eq!(de.last_completion_reason, None);
4026    }
4027
4028    /// M4: the persisted desired-state DTO carries the notification-authorization
4029    /// decision the iOS native layer now forwards under `notificationGranted`, so
4030    /// Rust's Notifier can degrade when notifications are denied. Absent (legacy /
4031    /// pre-intent) payloads default it to `None`.
4032    #[test]
4033    fn ios_desired_state_status_carries_notification_granted() {
4034        let granted = r#"{"desiredRunning":true,"notificationGranted":true}"#;
4035        let status: IOSDesiredStateStatus = serde_json::from_str(granted).unwrap();
4036        assert_eq!(status.notification_granted, Some(true));
4037
4038        let denied = r#"{"desiredRunning":true,"notificationGranted":false}"#;
4039        let status: IOSDesiredStateStatus = serde_json::from_str(denied).unwrap();
4040        assert_eq!(status.notification_granted, Some(false));
4041
4042        // Pre-intent payload without the key defaults to None (unknown decision).
4043        let legacy = r#"{"desiredRunning":false}"#;
4044        let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
4045        assert_eq!(de.notification_granted, None);
4046    }
4047
4048    // --- OsServiceInstallState tests ---
4049
4050    #[test]
4051    fn os_service_install_state_serde_roundtrip() {
4052        for variant in [
4053            OsServiceInstallState::NotInstalled,
4054            OsServiceInstallState::Installed,
4055            OsServiceInstallState::Running,
4056        ] {
4057            let json = serde_json::to_string(&variant).unwrap();
4058            let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
4059            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4060        }
4061    }
4062
4063    #[test]
4064    fn os_service_install_state_json_values_camel_case() {
4065        assert_eq!(
4066            serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
4067            "\"notInstalled\""
4068        );
4069        assert_eq!(
4070            serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
4071            "\"installed\""
4072        );
4073        assert_eq!(
4074            serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
4075            "\"running\""
4076        );
4077    }
4078
4079    // --- OsServiceStatus tests ---
4080
4081    #[test]
4082    fn os_service_status_serde_roundtrip() {
4083        let status = OsServiceStatus {
4084            label: "com.example.bg-service".into(),
4085            mode: "systemd".into(),
4086            installed: OsServiceInstallState::Running,
4087            ipc_connected: true,
4088            socket_path: Some("/tmp/test.sock".into()),
4089            last_error: None,
4090        };
4091        let json = serde_json::to_string(&status).unwrap();
4092        let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
4093        assert_eq!(de.label, "com.example.bg-service");
4094        assert_eq!(de.mode, "systemd");
4095        assert_eq!(de.installed, OsServiceInstallState::Running);
4096        assert!(de.ipc_connected);
4097        assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
4098        assert_eq!(de.last_error, None);
4099    }
4100
4101    #[test]
4102    fn os_service_status_json_keys_camel_case() {
4103        let status = OsServiceStatus {
4104            label: "test".into(),
4105            mode: "launchd".into(),
4106            installed: OsServiceInstallState::Installed,
4107            ipc_connected: false,
4108            socket_path: Some("/run/test.sock".into()),
4109            last_error: Some("timeout".into()),
4110        };
4111        let json = serde_json::to_string(&status).unwrap();
4112        assert!(json.contains("\"label\":"), "{json}");
4113        assert!(json.contains("\"mode\":"), "{json}");
4114        assert!(json.contains("\"installed\":"), "{json}");
4115        assert!(json.contains("\"ipcConnected\":"), "{json}");
4116        assert!(json.contains("\"socketPath\":"), "{json}");
4117        assert!(json.contains("\"lastError\":"), "{json}");
4118    }
4119
4120    #[test]
4121    fn os_service_status_optional_fields_absent_when_none() {
4122        let status = OsServiceStatus {
4123            label: "test".into(),
4124            mode: "systemd".into(),
4125            installed: OsServiceInstallState::NotInstalled,
4126            ipc_connected: false,
4127            socket_path: None,
4128            last_error: None,
4129        };
4130        let json = serde_json::to_string(&status).unwrap();
4131        assert!(!json.contains("socketPath"), "should be absent: {json}");
4132        assert!(!json.contains("lastError"), "should be absent: {json}");
4133    }
4134
4135    #[test]
4136    fn os_service_status_with_all_optional_fields() {
4137        let status = OsServiceStatus {
4138            label: "com.test".into(),
4139            mode: "launchd".into(),
4140            installed: OsServiceInstallState::Running,
4141            ipc_connected: true,
4142            socket_path: Some("/var/run/com.test.sock".into()),
4143            last_error: Some("connection refused".into()),
4144        };
4145        let json = serde_json::to_string(&status).unwrap();
4146        assert!(
4147            json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
4148            "{json}"
4149        );
4150        assert!(
4151            json.contains("\"lastError\":\"connection refused\""),
4152            "{json}"
4153        );
4154    }
4155
4156    #[test]
4157    fn os_service_status_deserialize_from_json() {
4158        let json = r#"{
4159            "label":"com.example.svc",
4160            "mode":"systemd",
4161            "installed":"running",
4162            "ipcConnected":true,
4163            "socketPath":"/tmp/test.sock"
4164        }"#;
4165        let status: OsServiceStatus = serde_json::from_str(json).unwrap();
4166        assert_eq!(status.label, "com.example.svc");
4167        assert_eq!(status.mode, "systemd");
4168        assert_eq!(status.installed, OsServiceInstallState::Running);
4169        assert!(status.ipc_connected);
4170        assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
4171        assert_eq!(status.last_error, None);
4172    }
4173
4174    // --- PendingTaskInfo tests ---
4175
4176    #[test]
4177    fn pending_task_info_serde_roundtrip() {
4178        let info = PendingTaskInfo {
4179            task_kind: "refresh".into(),
4180            identifier: "com.example.app.bg-refresh".into(),
4181            received_at: 1700000000.123,
4182            consumed_at: None,
4183        };
4184        let json = serde_json::to_string(&info).unwrap();
4185        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
4186        assert_eq!(de, info);
4187    }
4188
4189    #[test]
4190    fn pending_task_info_json_keys_camel_case() {
4191        let info = PendingTaskInfo {
4192            task_kind: "processing".into(),
4193            identifier: "test-id".into(),
4194            received_at: 123456.0,
4195            consumed_at: Some(123500.0),
4196        };
4197        let json = serde_json::to_string(&info).unwrap();
4198        assert!(json.contains("\"taskKind\":"), "{json}");
4199        assert!(json.contains("\"identifier\":"), "{json}");
4200        assert!(json.contains("\"receivedAt\":"), "{json}");
4201        assert!(json.contains("\"consumedAt\":"), "{json}");
4202    }
4203
4204    #[test]
4205    fn pending_task_info_from_native_response() {
4206        // Simulates the Swift response when a pending task exists
4207        let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
4208        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4209        assert_eq!(info.task_kind, "refresh");
4210        assert_eq!(info.identifier, "com.example.bg-refresh");
4211        assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
4212        assert_eq!(info.consumed_at, None);
4213    }
4214
4215    #[test]
4216    fn pending_task_info_processing_kind() {
4217        let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
4218        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4219        assert_eq!(info.task_kind, "processing");
4220        assert_eq!(info.identifier, "com.example.bg-processing");
4221        assert_eq!(info.consumed_at, None);
4222    }
4223
4224    #[test]
4225    fn pending_task_info_consumed_at_roundtrip() {
4226        let info = PendingTaskInfo {
4227            task_kind: "refresh".into(),
4228            identifier: "com.example.bg-refresh".into(),
4229            received_at: 1700000000.0,
4230            consumed_at: Some(1700000060.5),
4231        };
4232        let json = serde_json::to_string(&info).unwrap();
4233        assert!(json.contains("\"consumedAt\":1700000060.5"), "{json}");
4234        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
4235        assert_eq!(de.consumed_at, Some(1700000060.5));
4236    }
4237
4238    #[test]
4239    fn pending_task_info_consumed_at_null_deserializes_to_none() {
4240        let json = r#"{"taskKind":"refresh","identifier":"id","receivedAt":1.0,"consumedAt":null}"#;
4241        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4242        assert_eq!(info.consumed_at, None);
4243    }
4244
4245    // --- from_pending_payload gate (H5/M14 part 1): pending ⟺ consumedAt == nil ---
4246
4247    #[test]
4248    fn from_pending_payload_unconsumed_returns_some() {
4249        // A fresh, unconsumed record surfaces as a pending task.
4250        let value = serde_json::json!({
4251            "taskKind": "refresh",
4252            "identifier": "com.example.bg-refresh",
4253            "receivedAt": 1700000000.0,
4254            "consumedAt": serde_json::Value::Null,
4255        });
4256        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4257        assert_eq!(
4258            pending,
4259            Some(PendingTaskInfo {
4260                task_kind: "refresh".into(),
4261                identifier: "com.example.bg-refresh".into(),
4262                received_at: 1700000000.0,
4263                consumed_at: None,
4264            })
4265        );
4266    }
4267
4268    #[test]
4269    fn from_pending_payload_consumed_returns_none() {
4270        // H5: a record carrying a non-null `consumedAt` is stale — it must not
4271        // re-arm a cold auto-start. The old `taskKind.is_null()`-only gate would
4272        // have surfaced this as pending; the consumedAt gate suppresses it.
4273        let value = serde_json::json!({
4274            "taskKind": "processing",
4275            "identifier": "com.example.bg-processing",
4276            "receivedAt": 1700000000.0,
4277            "consumedAt": 1700000060.5,
4278        });
4279        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4280        assert_eq!(pending, None);
4281    }
4282
4283    #[test]
4284    fn from_pending_payload_no_record_returns_none() {
4285        // The all-null payload Swift resolves when nothing is pending.
4286        let value = serde_json::json!({
4287            "taskKind": serde_json::Value::Null,
4288            "identifier": serde_json::Value::Null,
4289            "receivedAt": serde_json::Value::Null,
4290            "consumedAt": serde_json::Value::Null,
4291        });
4292        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4293        assert_eq!(pending, None);
4294    }
4295
4296    // --- LifecycleState tests ---
4297
4298    #[test]
4299    fn lifecycle_state_all_variants_serde_roundtrip() {
4300        for variant in [
4301            LifecycleState::Idle,
4302            LifecycleState::Starting,
4303            LifecycleState::Running,
4304            LifecycleState::Stopping,
4305            LifecycleState::Stopped,
4306            LifecycleState::Recovering,
4307            LifecycleState::RecoveryPending,
4308            LifecycleState::Expired,
4309            LifecycleState::Blocked,
4310            LifecycleState::Error,
4311        ] {
4312            let json = serde_json::to_string(&variant).unwrap();
4313            let de: LifecycleState = serde_json::from_str(&json).unwrap();
4314            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4315        }
4316    }
4317
4318    #[test]
4319    fn lifecycle_state_json_values_are_camel_case() {
4320        assert_eq!(
4321            serde_json::to_string(&LifecycleState::Idle).unwrap(),
4322            "\"idle\""
4323        );
4324        assert_eq!(
4325            serde_json::to_string(&LifecycleState::Starting).unwrap(),
4326            "\"starting\""
4327        );
4328        assert_eq!(
4329            serde_json::to_string(&LifecycleState::Running).unwrap(),
4330            "\"running\""
4331        );
4332        assert_eq!(
4333            serde_json::to_string(&LifecycleState::Stopping).unwrap(),
4334            "\"stopping\""
4335        );
4336        assert_eq!(
4337            serde_json::to_string(&LifecycleState::Stopped).unwrap(),
4338            "\"stopped\""
4339        );
4340        assert_eq!(
4341            serde_json::to_string(&LifecycleState::Recovering).unwrap(),
4342            "\"recovering\""
4343        );
4344        assert_eq!(
4345            serde_json::to_string(&LifecycleState::RecoveryPending).unwrap(),
4346            "\"recoveryPending\""
4347        );
4348        assert_eq!(
4349            serde_json::to_string(&LifecycleState::Expired).unwrap(),
4350            "\"expired\""
4351        );
4352        assert_eq!(
4353            serde_json::to_string(&LifecycleState::Blocked).unwrap(),
4354            "\"blocked\""
4355        );
4356        assert_eq!(
4357            serde_json::to_string(&LifecycleState::Error).unwrap(),
4358            "\"error\""
4359        );
4360    }
4361
4362    // --- ServiceState → LifecycleState computation tests ---
4363
4364    #[test]
4365    fn service_state_idle_maps_to_lifecycle_idle() {
4366        assert_eq!(
4367            LifecycleState::from(ServiceState::Idle),
4368            LifecycleState::Idle
4369        );
4370    }
4371
4372    #[test]
4373    fn service_state_initializing_maps_to_lifecycle_starting() {
4374        assert_eq!(
4375            LifecycleState::from(ServiceState::Initializing),
4376            LifecycleState::Starting
4377        );
4378    }
4379
4380    #[test]
4381    fn service_state_running_maps_to_lifecycle_running() {
4382        assert_eq!(
4383            LifecycleState::from(ServiceState::Running),
4384            LifecycleState::Running
4385        );
4386    }
4387
4388    #[test]
4389    fn service_state_stopped_maps_to_lifecycle_stopped() {
4390        assert_eq!(
4391            LifecycleState::from(ServiceState::Stopped),
4392            LifecycleState::Stopped
4393        );
4394    }
4395
4396    // --- Severity tests ---
4397
4398    #[test]
4399    fn severity_all_variants_serde_roundtrip() {
4400        for variant in [Severity::Error, Severity::Warning, Severity::Info] {
4401            let json = serde_json::to_string(&variant).unwrap();
4402            let de: Severity = serde_json::from_str(&json).unwrap();
4403            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4404        }
4405    }
4406
4407    #[test]
4408    fn severity_json_values_are_camel_case() {
4409        assert_eq!(
4410            serde_json::to_string(&Severity::Error).unwrap(),
4411            "\"error\""
4412        );
4413        assert_eq!(
4414            serde_json::to_string(&Severity::Warning).unwrap(),
4415            "\"warning\""
4416        );
4417        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
4418    }
4419
4420    // --- ValidationIssue tests ---
4421
4422    #[test]
4423    fn validation_issue_serde_roundtrip() {
4424        let issue = ValidationIssue {
4425            severity: Severity::Error,
4426            code: "ANDROID_MISSING_PERMISSION".into(),
4427            message: "Missing FOREGROUND_SERVICE permission".into(),
4428            fix: Some("Add FOREGROUND_SERVICE permission to AndroidManifest.xml".into()),
4429            platform: Platform::Android,
4430        };
4431        let json = serde_json::to_string(&issue).unwrap();
4432        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4433        assert_eq!(de, issue);
4434    }
4435
4436    #[test]
4437    fn validation_issue_without_fix() {
4438        let issue = ValidationIssue {
4439            severity: Severity::Warning,
4440            code: "IOS_SCHEDULER_BUSY".into(),
4441            message: "BGTaskScheduler is busy".into(),
4442            fix: None,
4443            platform: Platform::Ios,
4444        };
4445        let json = serde_json::to_string(&issue).unwrap();
4446        assert!(
4447            !json.contains("fix"),
4448            "fix should be absent when None: {json}"
4449        );
4450        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4451        assert_eq!(de, issue);
4452    }
4453
4454    #[test]
4455    fn validation_issue_json_keys_camel_case() {
4456        let issue = ValidationIssue {
4457            severity: Severity::Info,
4458            code: "TEST".into(),
4459            message: "test".into(),
4460            fix: Some("do something".into()),
4461            platform: Platform::Linux,
4462        };
4463        let json = serde_json::to_string(&issue).unwrap();
4464        assert!(json.contains("\"severity\":"), "{json}");
4465        assert!(json.contains("\"code\":"), "{json}");
4466        assert!(json.contains("\"message\":"), "{json}");
4467        assert!(json.contains("\"fix\":"), "{json}");
4468        assert!(json.contains("\"platform\":"), "{json}");
4469    }
4470
4471    // --- LifecycleStatus tests ---
4472
4473    #[test]
4474    fn lifecycle_status_serde_roundtrip_minimal() {
4475        let status = LifecycleStatus {
4476            state: LifecycleState::Idle,
4477            desired_running: false,
4478            recovery_enabled: false,
4479            recovery_pending: false,
4480            recovery_reason: None,
4481            last_start_config: None,
4482            last_platform_state: None,
4483            last_platform_error: None,
4484            last_error: None,
4485            platform: Platform::Unknown,
4486            capabilities: PlatformCapabilities {
4487                platform: Platform::Unknown,
4488                lifecycle_mode: LifecycleMode::DesktopInProcess,
4489                survives_app_close: LifecycleGuarantee::Unsupported,
4490                survives_reboot: LifecycleGuarantee::Unsupported,
4491                survives_force_quit: LifecycleGuarantee::Unsupported,
4492                background_execution: LifecycleGuarantee::Unsupported,
4493                limitations: vec![],
4494                required_setup: vec![],
4495            },
4496            issues: vec![],
4497            native_running: None,
4498            native_foreground: None,
4499            adopted: None,
4500            degraded: None,
4501            degraded_reason: None,
4502            data_dir: None,
4503        };
4504        let json = serde_json::to_string(&status).unwrap();
4505        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4506        assert_eq!(de.state, LifecycleState::Idle);
4507        assert!(!de.desired_running);
4508        assert!(!de.recovery_enabled);
4509        assert!(!de.recovery_pending);
4510        assert_eq!(de.recovery_reason, None);
4511        assert_eq!(de.last_start_config, None);
4512        assert_eq!(de.last_platform_state, None);
4513        assert_eq!(de.last_platform_error, None);
4514        assert_eq!(de.last_error, None);
4515        assert_eq!(de.platform, Platform::Unknown);
4516        assert!(de.issues.is_empty());
4517    }
4518
4519    #[test]
4520    fn lifecycle_status_optional_fields_absent_when_none() {
4521        let status = LifecycleStatus {
4522            state: LifecycleState::Idle,
4523            desired_running: false,
4524            recovery_enabled: false,
4525            recovery_pending: false,
4526            recovery_reason: None,
4527            last_start_config: None,
4528            last_platform_state: None,
4529            last_platform_error: None,
4530            last_error: None,
4531            platform: Platform::Unknown,
4532            capabilities: PlatformCapabilities {
4533                platform: Platform::Unknown,
4534                lifecycle_mode: LifecycleMode::DesktopInProcess,
4535                survives_app_close: LifecycleGuarantee::Unsupported,
4536                survives_reboot: LifecycleGuarantee::Unsupported,
4537                survives_force_quit: LifecycleGuarantee::Unsupported,
4538                background_execution: LifecycleGuarantee::Unsupported,
4539                limitations: vec![],
4540                required_setup: vec![],
4541            },
4542            issues: vec![],
4543            native_running: None,
4544            native_foreground: None,
4545            adopted: None,
4546            degraded: None,
4547            degraded_reason: None,
4548            data_dir: None,
4549        };
4550        let json = serde_json::to_string(&status).unwrap();
4551        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
4552        assert!(
4553            !json.contains("lastStartConfig"),
4554            "should be absent: {json}"
4555        );
4556        assert!(
4557            !json.contains("lastPlatformState"),
4558            "should be absent: {json}"
4559        );
4560        assert!(
4561            !json.contains("lastPlatformError"),
4562            "should be absent: {json}"
4563        );
4564        assert!(!json.contains("lastError"), "should be absent: {json}");
4565        assert!(!json.contains("nativeRunning"), "should be absent: {json}");
4566        assert!(
4567            !json.contains("nativeForeground"),
4568            "should be absent: {json}"
4569        );
4570        assert!(!json.contains("adopted"), "should be absent: {json}");
4571        assert!(!json.contains("degraded"), "should be absent: {json}");
4572        assert!(!json.contains("degradedReason"), "should be absent: {json}");
4573    }
4574
4575    #[test]
4576    fn lifecycle_status_full_roundtrip_with_all_fields() {
4577        let status = LifecycleStatus {
4578            state: LifecycleState::Running,
4579            desired_running: true,
4580            recovery_enabled: true,
4581            recovery_pending: false,
4582            recovery_reason: Some("boot recovery".into()),
4583            last_start_config: Some(StartConfig {
4584                service_label: "Sync".into(),
4585                foreground_service_type: "dataSync".into(),
4586            }),
4587            last_platform_state: Some("running".into()),
4588            last_platform_error: Some("timeout exceeded".into()),
4589            last_error: Some("previous crash".into()),
4590            platform: Platform::Android,
4591            capabilities: PlatformCapabilities {
4592                platform: Platform::Android,
4593                lifecycle_mode: LifecycleMode::AndroidForegroundService,
4594                survives_app_close: LifecycleGuarantee::BestEffort,
4595                survives_reboot: LifecycleGuarantee::BestEffort,
4596                survives_force_quit: LifecycleGuarantee::Unsupported,
4597                background_execution: LifecycleGuarantee::Guaranteed,
4598                limitations: vec!["OEM battery optimization".into()],
4599                required_setup: vec!["FOREGROUND_SERVICE permission".into()],
4600            },
4601            issues: vec![ValidationIssue {
4602                severity: Severity::Warning,
4603                code: "ANDROID_BATTERY_OPTIMIZED".into(),
4604                message: "Battery optimization may kill the service".into(),
4605                fix: Some("Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS".into()),
4606                platform: Platform::Android,
4607            }],
4608            native_running: Some(true),
4609            native_foreground: Some(true),
4610            adopted: Some(false),
4611            degraded: Some(false),
4612            degraded_reason: None,
4613            data_dir: None,
4614        };
4615        let json = serde_json::to_string(&status).unwrap();
4616        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4617        assert_eq!(de.state, LifecycleState::Running);
4618        assert!(de.desired_running);
4619        assert!(de.recovery_enabled);
4620        assert!(!de.recovery_pending);
4621        assert_eq!(de.recovery_reason, Some("boot recovery".into()));
4622        assert!(de.last_start_config.is_some());
4623        assert_eq!(de.last_platform_state, Some("running".into()));
4624        assert_eq!(de.last_platform_error, Some("timeout exceeded".into()));
4625        assert_eq!(de.last_error, Some("previous crash".into()));
4626        assert_eq!(de.platform, Platform::Android);
4627        assert_eq!(de.issues.len(), 1);
4628        assert_eq!(de.native_running, Some(true));
4629        assert_eq!(de.native_foreground, Some(true));
4630        assert_eq!(de.adopted, Some(false));
4631        assert_eq!(de.degraded, Some(false));
4632        assert_eq!(de.degraded_reason, None);
4633    }
4634
4635    #[test]
4636    fn lifecycle_status_json_keys_camel_case() {
4637        let status = LifecycleStatus {
4638            state: LifecycleState::RecoveryPending,
4639            desired_running: true,
4640            recovery_enabled: true,
4641            recovery_pending: true,
4642            recovery_reason: Some("platform timeout".into()),
4643            last_start_config: None,
4644            last_platform_state: Some("timeout".into()),
4645            last_platform_error: None,
4646            last_error: None,
4647            platform: Platform::Ios,
4648            capabilities: PlatformCapabilities {
4649                platform: Platform::Ios,
4650                lifecycle_mode: LifecycleMode::IosBgTaskScheduler,
4651                survives_app_close: LifecycleGuarantee::BestEffort,
4652                survives_reboot: LifecycleGuarantee::BestEffort,
4653                survives_force_quit: LifecycleGuarantee::Unsupported,
4654                background_execution: LifecycleGuarantee::BestEffort,
4655                limitations: vec![],
4656                required_setup: vec![],
4657            },
4658            issues: vec![],
4659            native_running: None,
4660            native_foreground: None,
4661            adopted: None,
4662            degraded: None,
4663            degraded_reason: None,
4664            data_dir: None,
4665        };
4666        let json = serde_json::to_string(&status).unwrap();
4667        assert!(json.contains("\"state\":"), "{json}");
4668        assert!(json.contains("\"desiredRunning\":"), "{json}");
4669        assert!(json.contains("\"recoveryEnabled\":"), "{json}");
4670        assert!(json.contains("\"recoveryPending\":"), "{json}");
4671        assert!(json.contains("\"recoveryReason\":"), "{json}");
4672        assert!(json.contains("\"lastPlatformState\":"), "{json}");
4673        assert!(json.contains("\"platform\":"), "{json}");
4674        assert!(json.contains("\"capabilities\":"), "{json}");
4675        assert!(json.contains("\"issues\":"), "{json}");
4676    }
4677
4678    // --- tauri.conf.json camelCase deserialization proof (Step 1) ---
4679
4680    #[test]
4681    fn plugin_config_camel_case_json_deserializes_correctly() {
4682        let json = r#"{
4683            "androidForegroundServiceTypes": ["remoteMessaging", "dataSync"],
4684            "androidOnTimeout": "notifyUser",
4685            "androidNotificationChannelId": "bg_service",
4686            "androidNotificationChannelName": "Background Service",
4687            "androidShowStopAction": true,
4688            "iosSafetyTimeoutSecs": 28.0,
4689            "iosEarliestRefreshBeginMinutes": 15.0,
4690            "iosEarliestProcessingBeginMinutes": 15.0,
4691            "desktopServiceMode": "inProcess"
4692        }"#;
4693        let config: PluginConfig = serde_json::from_str(json).unwrap();
4694        assert_eq!(
4695            config.android_foreground_service_types,
4696            vec!["remoteMessaging", "dataSync"]
4697        );
4698        assert_eq!(config.android_on_timeout, "notifyUser");
4699        assert_eq!(config.android_notification_channel_id, "bg_service");
4700        assert_eq!(
4701            config.android_notification_channel_name,
4702            "Background Service"
4703        );
4704        assert!(config.android_show_stop_action);
4705        assert_eq!(config.ios_safety_timeout_secs, 28.0);
4706        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
4707        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
4708        #[cfg(feature = "desktop-service")]
4709        assert_eq!(config.desktop_service_mode, "inProcess");
4710    }
4711
4712    #[test]
4713    fn plugin_config_snake_case_json_falls_back_to_defaults() {
4714        let json = r#"{
4715            "android_foreground_service_types": ["remoteMessaging", "dataSync"],
4716            "desktop_service_mode": "inProcess"
4717        }"#;
4718        let config: PluginConfig = serde_json::from_str(json).unwrap();
4719        // snake_case keys are ignored by #[serde(rename_all = "camelCase")]
4720        assert_eq!(
4721            config.android_foreground_service_types,
4722            vec!["remoteMessaging"],
4723            "snake_case key should fall back to default"
4724        );
4725    }
4726
4727    // --- AndroidServiceState tests ---
4728
4729    #[test]
4730    fn android_service_state_deserialize_full_kotlin_output() {
4731        let json = r#"{
4732            "nativeRunning": true,
4733            "nativeForeground": true,
4734            "desiredRunning": true,
4735            "durableState": "running",
4736            "serviceLabel": "App Service",
4737            "foregroundServiceType": "remoteMessaging",
4738            "notificationId": 9001,
4739            "notificationChannelId": "bg_service",
4740            "recoveryPending": false,
4741            "recoveryReason": null,
4742            "lastPlatformError": null,
4743            "dataDir": "/data/data/com.example.app"
4744        }"#;
4745        let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4746        assert!(state.native_running);
4747        assert!(state.native_foreground);
4748        assert!(state.desired_running);
4749        assert_eq!(state.durable_state, "running");
4750        assert_eq!(state.service_label, Some("App Service".into()));
4751        assert_eq!(
4752            state.foreground_service_type,
4753            Some("remoteMessaging".into())
4754        );
4755        assert_eq!(state.notification_id, Some(9001));
4756        assert_eq!(state.notification_channel_id, Some("bg_service".into()));
4757        assert!(!state.recovery_pending);
4758        assert_eq!(state.recovery_reason, None);
4759        assert_eq!(state.last_platform_error, None);
4760        assert_eq!(state.data_dir, "/data/data/com.example.app");
4761    }
4762
4763    #[test]
4764    fn android_service_state_deserialize_minimal() {
4765        let json = r#"{
4766            "nativeRunning": false,
4767            "nativeForeground": false,
4768            "desiredRunning": false,
4769            "durableState": "stopped",
4770            "recoveryPending": false,
4771            "dataDir": ""
4772        }"#;
4773        let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4774        assert!(!state.native_running);
4775        assert!(!state.native_foreground);
4776        assert!(!state.desired_running);
4777        assert_eq!(state.durable_state, "stopped");
4778        assert_eq!(state.service_label, None);
4779        assert_eq!(state.foreground_service_type, None);
4780        assert_eq!(state.notification_id, None);
4781        assert_eq!(state.notification_channel_id, None);
4782        assert!(!state.recovery_pending);
4783        assert_eq!(state.recovery_reason, None);
4784        assert_eq!(state.last_platform_error, None);
4785        assert_eq!(state.data_dir, "");
4786    }
4787
4788    #[test]
4789    fn android_service_state_serde_roundtrip() {
4790        let state = AndroidServiceState {
4791            native_running: true,
4792            native_foreground: false,
4793            desired_running: true,
4794            durable_state: "starting".into(),
4795            service_label: Some("test".into()),
4796            foreground_service_type: None,
4797            notification_id: None,
4798            notification_channel_id: None,
4799            recovery_pending: true,
4800            recovery_reason: Some("boot".into()),
4801            last_platform_error: Some("crash".into()),
4802            data_dir: "/data/test".into(),
4803        };
4804        let json = serde_json::to_string(&state).unwrap();
4805        let de: AndroidServiceState = serde_json::from_str(&json).unwrap();
4806        assert_eq!(de, state);
4807    }
4808
4809    #[test]
4810    fn android_service_state_optional_fields_absent_when_none() {
4811        let state = AndroidServiceState {
4812            native_running: false,
4813            native_foreground: false,
4814            desired_running: false,
4815            durable_state: "unknown".into(),
4816            service_label: None,
4817            foreground_service_type: None,
4818            notification_id: None,
4819            notification_channel_id: None,
4820            recovery_pending: false,
4821            recovery_reason: None,
4822            last_platform_error: None,
4823            data_dir: "".into(),
4824        };
4825        let json = serde_json::to_string(&state).unwrap();
4826        assert!(!json.contains("serviceLabel"), "absent: {json}");
4827        assert!(!json.contains("foregroundServiceType"), "absent: {json}");
4828        assert!(!json.contains("notificationId"), "absent: {json}");
4829        assert!(!json.contains("notificationChannelId"), "absent: {json}");
4830        assert!(!json.contains("recoveryReason"), "absent: {json}");
4831        assert!(!json.contains("lastPlatformError"), "absent: {json}");
4832    }
4833
4834    // --- LifecycleStatus native state fields tests ---
4835
4836    #[test]
4837    fn lifecycle_status_native_fields_serialize_when_present() {
4838        let status = LifecycleStatus {
4839            state: LifecycleState::Running,
4840            desired_running: true,
4841            recovery_enabled: true,
4842            recovery_pending: false,
4843            recovery_reason: None,
4844            last_start_config: None,
4845            last_platform_state: None,
4846            last_platform_error: None,
4847            last_error: None,
4848            platform: Platform::Android,
4849            capabilities: PlatformCapabilities {
4850                platform: Platform::Android,
4851                lifecycle_mode: LifecycleMode::AndroidForegroundService,
4852                survives_app_close: LifecycleGuarantee::BestEffort,
4853                survives_reboot: LifecycleGuarantee::BestEffort,
4854                survives_force_quit: LifecycleGuarantee::Unsupported,
4855                background_execution: LifecycleGuarantee::Guaranteed,
4856                limitations: vec![],
4857                required_setup: vec![],
4858            },
4859            issues: vec![],
4860            native_running: Some(true),
4861            native_foreground: Some(true),
4862            adopted: Some(false),
4863            degraded: Some(false),
4864            degraded_reason: Some("native running but Rust idle".into()),
4865            data_dir: None,
4866        };
4867        let json = serde_json::to_string(&status).unwrap();
4868        assert!(json.contains("\"nativeRunning\":true"), "{json}");
4869        assert!(json.contains("\"nativeForeground\":true"), "{json}");
4870        assert!(json.contains("\"adopted\":false"), "{json}");
4871        assert!(json.contains("\"degraded\":false"), "{json}");
4872        assert!(
4873            json.contains("\"degradedReason\":\"native running but Rust idle\""),
4874            "{json}"
4875        );
4876    }
4877
4878    #[test]
4879    fn lifecycle_status_native_fields_deserialize_from_json() {
4880        let json = r#"{
4881            "state": "running",
4882            "desiredRunning": true,
4883            "recoveryEnabled": true,
4884            "recoveryPending": false,
4885            "platform": "android",
4886            "capabilities": {
4887                "platform": "android",
4888                "lifecycleMode": "androidForegroundService",
4889                "survivesAppClose": "bestEffort",
4890                "survivesReboot": "bestEffort",
4891                "survivesForceQuit": "unsupported",
4892                "backgroundExecution": "guaranteed",
4893                "limitations": [],
4894                "requiredSetup": []
4895            },
4896            "issues": [],
4897            "nativeRunning": true,
4898            "nativeForeground": false,
4899            "adopted": true,
4900            "degraded": true,
4901            "degradedReason": "split-brain detected"
4902        }"#;
4903        let status: LifecycleStatus = serde_json::from_str(json).unwrap();
4904        assert_eq!(status.native_running, Some(true));
4905        assert_eq!(status.native_foreground, Some(false));
4906        assert_eq!(status.adopted, Some(true));
4907        assert_eq!(status.degraded, Some(true));
4908        assert_eq!(status.degraded_reason, Some("split-brain detected".into()));
4909    }
4910}