use std::sync::Mutex; use serde::{Deserialize, Serialize}; use crate::progress::ReadingPosition; /// Maximum number of events before oldest are dropped to prevent unbounded memory growth. const MAX_BUFFER_SIZE: usize = 1000; // Global event buffer, protected by a Mutex for thread safety. static EVENT_BUFFER: Mutex> = Mutex::new(Vec::new()); /// Push a reading event into the global buffer. /// If the buffer exceeds MAX_BUFFER_SIZE, the oldest event is dropped. #[deprecated(since = "0.2.0", note = "Use events_v2::push_heartbeat_v2 etc. instead")] pub fn push_reading_event(event: ReadingEvent) { match EVENT_BUFFER.lock() { Ok(mut buf) => { if buf.len() >= MAX_BUFFER_SIZE { buf.remove(0); } buf.push(event); } Err(_) => { /* poison: silently drop event */ } } } #[deprecated(since = "0.2.0", note = "Use events_v2::push_position_changed_v2 instead")] /// Record a position change as a PositionChanged event. pub fn update_reading_position(material_id: &str, position: ReadingPosition) { let event = ReadingEvent::PositionChanged { material_id: material_id.to_string(), position, timestamp_ms: now_ms(), }; push_reading_event(event); } /// Export all pending events without clearing. #[deprecated(since = "0.2.0", note = "Use events_v2::export_pending_events_v2 instead")] pub fn export_pending_events() -> Vec { EVENT_BUFFER.lock().map(|buf| buf.clone()).unwrap_or_default() } /// Remove the first `count` events after successful upload. pub fn clear_exported_events(count: usize) { if let Ok(mut buf) = EVENT_BUFFER.lock() { let n = count.min(buf.len()); buf.drain(..n); } } fn now_ms() -> i64 { use std::time::SystemTime; SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Enum)] #[serde(tag = "type")] pub enum ReadingEvent { MaterialOpened { material_id: String, timestamp_ms: i64, }, MaterialClosed { material_id: String, timestamp_ms: i64, active_seconds: u32, }, PositionChanged { material_id: String, position: ReadingPosition, timestamp_ms: i64, }, Heartbeat { material_id: String, active_seconds: u32, position: Option, timestamp_ms: i64, }, MarkedAsRead { material_id: String, timestamp_ms: i64, }, } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; // Serialize event buffer tests to prevent races on the global EVENT_BUFFER. static TEST_LOCK: Mutex<()> = Mutex::new(()); fn setup() { clear_exported_events(usize::MAX); } #[test] fn test_material_opened_serde() { let e = ReadingEvent::MaterialOpened { material_id: "abc".into(), timestamp_ms: 1000, }; let json = serde_json::to_string(&e).unwrap(); assert!(json.contains("\"type\":\"MaterialOpened\"")); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_material_closed_serde() { let e = ReadingEvent::MaterialClosed { material_id: "abc".into(), timestamp_ms: 2000, active_seconds: 120, }; let json = serde_json::to_string(&e).unwrap(); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_position_changed_serde() { let e = ReadingEvent::PositionChanged { material_id: "abc".into(), position: ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }, timestamp_ms: 3000, }; let json = serde_json::to_string(&e).unwrap(); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_heartbeat_serde() { let e = ReadingEvent::Heartbeat { material_id: "abc".into(), active_seconds: 15, position: None, timestamp_ms: 4000, }; let json = serde_json::to_string(&e).unwrap(); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_heartbeat_with_position_serde() { let e = ReadingEvent::Heartbeat { material_id: "abc".into(), active_seconds: 15, position: Some(ReadingPosition::Pdf { page_number: 3, page_progress: 0.5, overall_progress: 0.1 }), timestamp_ms: 5000, }; let json = serde_json::to_string(&e).unwrap(); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_marked_as_read_serde() { let e = ReadingEvent::MarkedAsRead { material_id: "abc".into(), timestamp_ms: 6000, }; let json = serde_json::to_string(&e).unwrap(); let back: ReadingEvent = serde_json::from_str(&json).unwrap(); assert_eq!(back, e); } #[test] fn test_push_and_export() { let _guard = TEST_LOCK.lock().unwrap(); setup(); push_reading_event(ReadingEvent::MaterialOpened { material_id: "m1".into(), timestamp_ms: 1000, }); push_reading_event(ReadingEvent::MarkedAsRead { material_id: "m1".into(), timestamp_ms: 2000, }); let events = export_pending_events(); assert_eq!(events.len(), 2); } #[test] fn test_clear_exported() { let _guard = TEST_LOCK.lock().unwrap(); setup(); push_reading_event(ReadingEvent::MaterialOpened { material_id: "m1".into(), timestamp_ms: 1000, }); push_reading_event(ReadingEvent::MaterialOpened { material_id: "m2".into(), timestamp_ms: 2000, }); push_reading_event(ReadingEvent::MaterialOpened { material_id: "m3".into(), timestamp_ms: 3000, }); clear_exported_events(2); let events = export_pending_events(); assert_eq!(events.len(), 1); } #[test] fn test_update_reading_position() { let _guard = TEST_LOCK.lock().unwrap(); setup(); update_reading_position( "m1", ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }, ); let events = export_pending_events(); assert_eq!(events.len(), 1); assert!(matches!(events[0], ReadingEvent::PositionChanged { .. })); } #[test] fn test_empty_export() { let _guard = TEST_LOCK.lock().unwrap(); setup(); let events = export_pending_events(); assert!(events.is_empty()); } }