use crate::compat::Instant;
#[cfg(all(feature = "serde", widgets_unstripped))]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(all(feature = "serde", widgets_unstripped), derive(Serialize, Deserialize))]
pub enum AppLifecycleState {
Starting,
Foreground,
Background,
Suspended,
Terminating,
}
impl AppLifecycleState {
pub fn is_active(&self) -> bool {
matches!(self, AppLifecycleState::Foreground)
}
pub fn is_visible(&self) -> bool {
matches!(self, AppLifecycleState::Foreground | AppLifecycleState::Starting)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleEvent {
WillEnterForeground,
DidEnterForeground,
WillEnterBackground,
DidEnterBackground,
WillTerminate,
MemoryWarning,
StateRestored,
}
pub type LifecycleCallback = Box<dyn FnMut(LifecycleEvent) + Send>;
#[cfg(all(feature = "serde_json", feature = "serde", widgets_unstripped))]
const MAX_BACKGROUND_SECS: f64 = (u64::MAX as f64) / 2.0;
#[derive(Debug, Clone)]
#[cfg_attr(all(feature = "serde", widgets_unstripped), derive(Serialize, Deserialize))]
struct LifecycleSnapshot {
state: AppLifecycleState,
total_background_secs: f64,
}
pub struct AppLifecycle {
state: AppLifecycleState,
started_at: Instant,
background_entry: Option<Instant>,
total_background_duration: std::time::Duration,
listeners: Vec<LifecycleCallback>,
}
impl AppLifecycle {
pub fn new() -> Self {
Self {
state: AppLifecycleState::Starting,
started_at: Instant::now(),
background_entry: None,
total_background_duration: std::time::Duration::ZERO,
listeners: Vec::new(),
}
}
pub fn transition(&mut self, new_state: AppLifecycleState) {
let old_state = self.state;
if old_state == new_state {
return;
}
let was_background =
matches!(old_state, AppLifecycleState::Background | AppLifecycleState::Suspended);
let is_background =
matches!(new_state, AppLifecycleState::Background | AppLifecycleState::Suspended);
if was_background {
if let Some(entry) = self.background_entry.take() {
self.total_background_duration += entry.elapsed();
}
}
self.background_entry = if is_background { Some(Instant::now()) } else { None };
self.state = new_state;
match (old_state, new_state) {
(AppLifecycleState::Background, AppLifecycleState::Foreground)
| (AppLifecycleState::Suspended, AppLifecycleState::Foreground)
| (AppLifecycleState::Starting, AppLifecycleState::Foreground) => {
self.fire(LifecycleEvent::WillEnterForeground);
self.fire(LifecycleEvent::DidEnterForeground);
}
(AppLifecycleState::Foreground, AppLifecycleState::Background) => {
self.fire(LifecycleEvent::WillEnterBackground);
self.fire(LifecycleEvent::DidEnterBackground);
}
(AppLifecycleState::Foreground, AppLifecycleState::Suspended) => {
self.fire(LifecycleEvent::WillEnterBackground);
self.fire(LifecycleEvent::DidEnterBackground);
}
(_, AppLifecycleState::Terminating) => {
self.fire(LifecycleEvent::WillTerminate);
}
_ => {}
}
}
pub fn state(&self) -> AppLifecycleState {
self.state
}
pub fn uptime(&self) -> std::time::Duration {
self.started_at.elapsed()
}
pub fn total_background_duration(&self) -> std::time::Duration {
let active = self.background_entry.map_or(std::time::Duration::ZERO, |entry| {
if self.state == AppLifecycleState::Background
|| self.state == AppLifecycleState::Suspended
{
entry.elapsed()
} else {
std::time::Duration::ZERO
}
});
self.total_background_duration + active
}
pub fn add_listener(&mut self, callback: LifecycleCallback) {
self.listeners.push(callback);
}
fn fire(&mut self, event: LifecycleEvent) {
for cb in &mut self.listeners {
cb(event);
}
}
#[cfg(all(feature = "serde_json", feature = "serde", widgets_unstripped))]
pub fn serialize_state(&self) -> Result<String, String> {
let snapshot = LifecycleSnapshot {
state: self.state,
total_background_secs: self.total_background_duration().as_secs_f64(),
};
serde_json::to_string(&snapshot).map_err(|e| {
format!("lifecycle snapshot could not be serialized to JSON (serde_json failed): {e}")
})
}
#[cfg(all(feature = "serde_json", feature = "serde", widgets_unstripped))]
pub fn deserialize_state(data: &str) -> Result<Self, String> {
let snapshot: LifecycleSnapshot = serde_json::from_str(data).map_err(|e| {
format!(
"lifecycle state ({} bytes) is not valid LifecycleSnapshot JSON: {e}",
data.len()
)
})?;
let secs = snapshot.total_background_secs;
if !secs.is_finite() {
return Err(format!(
"lifecycle field 'total_background_secs' must be finite, got {secs}; refusing to \
restore the snapshot"
));
}
if secs < 0.0 {
return Err(format!(
"lifecycle field 'total_background_secs' must not be negative, got {secs}; \
refusing to restore the snapshot"
));
}
if secs > MAX_BACKGROUND_SECS {
return Err(format!(
"lifecycle field 'total_background_secs' is too large ({secs} exceeds the \
supported maximum of {MAX_BACKGROUND_SECS}); refusing to restore the snapshot"
));
}
let mut lc = Self {
state: snapshot.state,
started_at: Instant::now(),
background_entry: None,
total_background_duration: std::time::Duration::from_secs_f64(secs),
listeners: Vec::new(),
};
if snapshot.state == AppLifecycleState::Background
|| snapshot.state == AppLifecycleState::Suspended
{
lc.background_entry = Some(Instant::now());
}
lc.fire(LifecycleEvent::StateRestored);
Ok(lc)
}
pub fn emit_memory_warning(&mut self) {
self.fire(LifecycleEvent::MemoryWarning);
}
}
crate::impl_default_via_new!(AppLifecycle);
#[cfg(test)]
mod tests {
use super::*;
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
use core::time::Duration;
#[test]
fn test_state_transitions() {
let mut lc = AppLifecycle::new();
assert_eq!(lc.state(), AppLifecycleState::Starting);
assert!(lc.state().is_visible());
assert!(!lc.state().is_active());
lc.transition(AppLifecycleState::Foreground);
assert_eq!(lc.state(), AppLifecycleState::Foreground);
assert!(lc.state().is_active());
assert!(lc.state().is_visible());
lc.transition(AppLifecycleState::Background);
assert_eq!(lc.state(), AppLifecycleState::Background);
assert!(!lc.state().is_active());
assert!(!lc.state().is_visible());
lc.transition(AppLifecycleState::Suspended);
assert_eq!(lc.state(), AppLifecycleState::Suspended);
assert!(!lc.state().is_active());
assert!(!lc.state().is_visible());
lc.transition(AppLifecycleState::Terminating);
assert_eq!(lc.state(), AppLifecycleState::Terminating);
}
#[test]
fn test_same_state_transition_noop() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
let call_count = Arc::new(AtomicUsize::new(0));
let count = Arc::clone(&call_count);
lc.add_listener(Box::new(move |_| {
count.fetch_add(1, Ordering::SeqCst);
}));
lc.transition(AppLifecycleState::Foreground);
assert_eq!(call_count.load(Ordering::SeqCst), 0);
}
#[test]
fn test_listener_notification() {
let mut lc = AppLifecycle::new();
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let ev = Arc::clone(&events);
lc.add_listener(Box::new(move |e| {
ev.lock().unwrap().push(e);
}));
lc.transition(AppLifecycleState::Foreground);
lc.transition(AppLifecycleState::Background);
lc.transition(AppLifecycleState::Terminating);
let recorded = events.lock().unwrap();
assert_eq!(recorded.len(), 5);
assert_eq!(recorded[0], LifecycleEvent::WillEnterForeground);
assert_eq!(recorded[1], LifecycleEvent::DidEnterForeground);
assert_eq!(recorded[2], LifecycleEvent::WillEnterBackground);
assert_eq!(recorded[3], LifecycleEvent::DidEnterBackground);
assert_eq!(recorded[4], LifecycleEvent::WillTerminate);
}
#[test]
fn test_serialization_roundtrip() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
lc.transition(AppLifecycleState::Background);
std::thread::sleep(Duration::from_millis(5));
lc.transition(AppLifecycleState::Foreground);
let json = lc.serialize_state().expect("serialize_state should succeed");
let restored =
AppLifecycle::deserialize_state(&json).expect("deserialize_state should succeed");
assert_eq!(restored.state(), AppLifecycleState::Foreground);
assert!(restored.total_background_duration() > Duration::ZERO);
}
#[test]
fn test_uptime_increases() {
let lc = AppLifecycle::new();
let u1 = lc.uptime();
std::thread::sleep(Duration::from_millis(10));
let u2 = lc.uptime();
assert!(u2 >= u1 + Duration::from_millis(5));
}
#[test]
fn test_background_duration_accumulates() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
assert_eq!(lc.total_background_duration(), Duration::ZERO);
lc.transition(AppLifecycleState::Background);
std::thread::sleep(Duration::from_millis(10));
lc.transition(AppLifecycleState::Foreground);
let bg = lc.total_background_duration();
assert!(bg >= Duration::from_millis(5));
lc.transition(AppLifecycleState::Background);
std::thread::sleep(Duration::from_millis(5));
lc.transition(AppLifecycleState::Foreground);
let bg2 = lc.total_background_duration();
assert!(bg2 > bg);
}
#[test]
fn test_memory_warning() {
let mut lc = AppLifecycle::new();
let warned = Arc::new(AtomicUsize::new(0));
let w = Arc::clone(&warned);
lc.add_listener(Box::new(move |e| {
if e == LifecycleEvent::MemoryWarning {
w.fetch_add(1, Ordering::SeqCst);
}
}));
lc.emit_memory_warning();
assert_eq!(warned.load(Ordering::SeqCst), 1);
}
#[test]
fn test_deserialize_preserves_state() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
let json = lc.serialize_state().unwrap();
let d = AppLifecycle::deserialize_state(&json).unwrap();
assert_eq!(d.state(), AppLifecycleState::Foreground);
assert!(d.state().is_active());
assert!(d.state().is_visible());
assert!(d.uptime() < Duration::from_millis(100));
}
#[test]
fn deserialize_rejects_negative_background_secs() {
let result =
AppLifecycle::deserialize_state(r#"{"state":"Foreground","total_background_secs":-1}"#);
let error = result.err().expect("a negative duration must be rejected, not accepted");
assert!(
error.contains("total_background_secs"),
"the error must name the offending field: {error}"
);
}
#[test]
fn deserialize_rejects_non_finite_background_secs() {
let result = AppLifecycle::deserialize_state(
r#"{"state":"Foreground","total_background_secs":1e308}"#,
);
let error = result.err().expect("an out-of-range duration must be rejected, not accepted");
assert!(
error.contains("total_background_secs"),
"the error must name the offending field: {error}"
);
}
#[test]
fn deserialize_accepts_zero_and_a_normal_duration() {
let zero = AppLifecycle::deserialize_state(
r#"{"state":"Foreground","total_background_secs":0.0}"#,
)
.expect("zero must be accepted");
assert_eq!(zero.total_background_duration(), Duration::ZERO);
let normal = AppLifecycle::deserialize_state(
r#"{"state":"Foreground","total_background_secs":12.5}"#,
)
.expect("a normal duration must be accepted");
assert_eq!(normal.total_background_duration(), Duration::from_secs_f64(12.5));
}
#[test]
fn suspended_counts_as_background_time() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
assert_eq!(lc.total_background_duration(), Duration::ZERO);
lc.transition(AppLifecycleState::Suspended);
std::thread::sleep(Duration::from_millis(10));
assert!(lc.total_background_duration() >= Duration::from_millis(5));
lc.transition(AppLifecycleState::Foreground);
let after = lc.total_background_duration();
assert!(after >= Duration::from_millis(5), "the suspended interval must be accumulated");
}
#[test]
fn background_to_suspended_keeps_both_intervals() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
lc.transition(AppLifecycleState::Background);
std::thread::sleep(Duration::from_millis(8));
lc.transition(AppLifecycleState::Suspended);
std::thread::sleep(Duration::from_millis(8));
lc.transition(AppLifecycleState::Foreground);
let total = lc.total_background_duration();
assert!(
total >= Duration::from_millis(12),
"both the background and the suspended intervals must be counted, got {total:?}"
);
}
#[test]
fn a_foreground_state_has_no_open_interval() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
lc.transition(AppLifecycleState::Background);
lc.transition(AppLifecycleState::Foreground);
let before = lc.total_background_duration();
std::thread::sleep(Duration::from_millis(10));
assert_eq!(lc.total_background_duration(), before);
}
#[test]
fn deserialize_from_suspended_tracks_background() {
let mut lc = AppLifecycle::new();
lc.transition(AppLifecycleState::Foreground);
lc.transition(AppLifecycleState::Suspended);
std::thread::sleep(Duration::from_millis(5));
let json = lc.serialize_state().unwrap();
let restored =
AppLifecycle::deserialize_state(&json).expect("a suspended snapshot restores");
assert_eq!(restored.state(), AppLifecycleState::Suspended);
assert!(
restored.total_background_duration() > Duration::ZERO,
"a restored suspended state must keep counting background time"
);
}
}