activity.rs (12340B)
1 use radroots_app_view::{ 2 ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, SettingsPreference, 3 SettingsSection, 4 }; 5 use rusqlite::{Connection, params}; 6 7 use crate::AppSqliteError; 8 9 pub const APP_ACTIVITY_CONTEXT_LIMIT: usize = 64; 10 pub const APP_ACTIVITY_RETENTION_LIMIT: i64 = 5_000; 11 12 pub struct AppActivityRepository<'a> { 13 connection: &'a Connection, 14 } 15 16 impl<'a> AppActivityRepository<'a> { 17 pub fn new(connection: &'a Connection) -> Self { 18 Self { connection } 19 } 20 21 pub fn record(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> { 22 let activity_event_id = ActivityEventId::new().to_string(); 23 let event_kind = kind.storage_key(); 24 let settings_section = settings_section_value(kind); 25 let settings_preference = settings_preference_value(kind); 26 let preference_enabled = preference_enabled_value(kind); 27 28 self.connection 29 .execute( 30 "INSERT INTO activity_events ( 31 activity_event_id, 32 event_kind, 33 settings_section, 34 settings_preference, 35 preference_enabled 36 ) VALUES (?1, ?2, ?3, ?4, ?5)", 37 params![ 38 activity_event_id, 39 event_kind, 40 settings_section, 41 settings_preference, 42 preference_enabled, 43 ], 44 ) 45 .map_err(|source| AppSqliteError::Query { 46 operation: "record activity event", 47 source, 48 })?; 49 50 self.trim_retained_events(APP_ACTIVITY_RETENTION_LIMIT)?; 51 52 Ok(()) 53 } 54 55 pub fn load_recent(&self, limit: usize) -> Result<Vec<AppActivityEvent>, AppSqliteError> { 56 let mut statement = self 57 .connection 58 .prepare( 59 "SELECT 60 activity_event_id, 61 recorded_at, 62 event_kind, 63 settings_section, 64 settings_preference, 65 preference_enabled 66 FROM activity_events 67 ORDER BY recorded_at DESC, activity_event_id DESC 68 LIMIT ?1", 69 ) 70 .map_err(|source| AppSqliteError::Query { 71 operation: "prepare recent activity query", 72 source, 73 })?; 74 let rows = statement 75 .query_map([limit as i64], |row| { 76 let activity_event_id = row.get::<_, String>(0)?; 77 let recorded_at = row.get::<_, String>(1)?; 78 let event_kind = row.get::<_, String>(2)?; 79 let settings_section = row.get::<_, Option<String>>(3)?; 80 let settings_preference = row.get::<_, Option<String>>(4)?; 81 let preference_enabled = row.get::<_, Option<i64>>(5)?; 82 83 Ok(( 84 activity_event_id, 85 recorded_at, 86 event_kind, 87 settings_section, 88 settings_preference, 89 preference_enabled, 90 )) 91 }) 92 .map_err(|source| AppSqliteError::Query { 93 operation: "query recent activity events", 94 source, 95 })?; 96 97 rows.map(|row| { 98 let ( 99 activity_event_id, 100 recorded_at, 101 event_kind, 102 settings_section, 103 settings_preference, 104 preference_enabled, 105 ) = row.map_err(|source| AppSqliteError::Query { 106 operation: "read recent activity event row", 107 source, 108 })?; 109 110 decode_activity_event( 111 &activity_event_id, 112 recorded_at, 113 event_kind, 114 settings_section, 115 settings_preference, 116 preference_enabled, 117 ) 118 }) 119 .collect() 120 } 121 122 pub fn load_context(&self, limit: usize) -> Result<AppActivityContext, AppSqliteError> { 123 Ok(AppActivityContext::from_recent_events( 124 self.load_recent(limit)?, 125 )) 126 } 127 128 fn trim_retained_events(&self, retention_limit: i64) -> Result<(), AppSqliteError> { 129 self.connection 130 .execute( 131 "DELETE FROM activity_events 132 WHERE activity_event_id IN ( 133 SELECT activity_event_id 134 FROM activity_events 135 ORDER BY recorded_at DESC, activity_event_id DESC 136 LIMIT -1 OFFSET ?1 137 )", 138 [retention_limit], 139 ) 140 .map_err(|source| AppSqliteError::Query { 141 operation: "trim retained activity events", 142 source, 143 })?; 144 145 Ok(()) 146 } 147 } 148 149 fn decode_activity_event( 150 activity_event_id: &str, 151 recorded_at: String, 152 event_kind: String, 153 settings_section: Option<String>, 154 settings_preference: Option<String>, 155 preference_enabled: Option<i64>, 156 ) -> Result<AppActivityEvent, AppSqliteError> { 157 let kind = match event_kind.as_str() { 158 "home_opened" => AppActivityKind::HomeOpened, 159 "settings_opened" => AppActivityKind::SettingsOpened { 160 section: decode_settings_section("settings_section", settings_section)?, 161 }, 162 "settings_section_selected" => AppActivityKind::SettingsSectionSelected { 163 section: decode_settings_section("settings_section", settings_section)?, 164 }, 165 "settings_preference_updated" => AppActivityKind::SettingsPreferenceUpdated { 166 preference: decode_settings_preference("settings_preference", settings_preference)?, 167 enabled: decode_preference_enabled(preference_enabled)?, 168 }, 169 other => { 170 return Err(AppSqliteError::DecodeEnum { 171 field: "event_kind", 172 value: other.to_owned(), 173 }); 174 } 175 }; 176 177 Ok(AppActivityEvent { 178 activity_event_id: activity_event_id 179 .parse() 180 .map_err(|_| AppSqliteError::DecodeId { 181 field: "activity_event_id", 182 value: activity_event_id.to_owned(), 183 })?, 184 recorded_at, 185 kind, 186 }) 187 } 188 189 fn decode_settings_section( 190 field: &'static str, 191 value: Option<String>, 192 ) -> Result<SettingsSection, AppSqliteError> { 193 match value.as_deref() { 194 Some("account") => Ok(SettingsSection::Account), 195 Some("farm") => Ok(SettingsSection::Farm), 196 Some("settings") => Ok(SettingsSection::Settings), 197 Some("about") => Ok(SettingsSection::About), 198 Some(other) => Err(AppSqliteError::DecodeEnum { 199 field, 200 value: other.to_owned(), 201 }), 202 None => Err(AppSqliteError::MissingColumn { field }), 203 } 204 } 205 206 fn decode_settings_preference( 207 field: &'static str, 208 value: Option<String>, 209 ) -> Result<SettingsPreference, AppSqliteError> { 210 match value.as_deref() { 211 Some("allow_relay_connections") => Ok(SettingsPreference::AllowRelayConnections), 212 Some("use_media_servers") => Ok(SettingsPreference::UseMediaServers), 213 Some("use_nip05") => Ok(SettingsPreference::UseNip05), 214 Some("launch_at_login") => Ok(SettingsPreference::LaunchAtLogin), 215 Some(other) => Err(AppSqliteError::DecodeEnum { 216 field, 217 value: other.to_owned(), 218 }), 219 None => Err(AppSqliteError::MissingColumn { field }), 220 } 221 } 222 223 fn decode_preference_enabled(value: Option<i64>) -> Result<bool, AppSqliteError> { 224 match value { 225 Some(0) => Ok(false), 226 Some(1) => Ok(true), 227 Some(other) => Err(AppSqliteError::DecodeEnum { 228 field: "preference_enabled", 229 value: other.to_string(), 230 }), 231 None => Err(AppSqliteError::MissingColumn { 232 field: "preference_enabled", 233 }), 234 } 235 } 236 237 fn settings_section_value(kind: &AppActivityKind) -> Option<&'static str> { 238 match kind { 239 AppActivityKind::SettingsOpened { section } 240 | AppActivityKind::SettingsSectionSelected { section } => Some(match section { 241 SettingsSection::Account => "account", 242 SettingsSection::Farm => "farm", 243 SettingsSection::Settings => "settings", 244 SettingsSection::About => "about", 245 }), 246 _ => None, 247 } 248 } 249 250 fn settings_preference_value(kind: &AppActivityKind) -> Option<&'static str> { 251 match kind { 252 AppActivityKind::SettingsPreferenceUpdated { preference, .. } => { 253 Some(preference.storage_key()) 254 } 255 _ => None, 256 } 257 } 258 259 fn preference_enabled_value(kind: &AppActivityKind) -> Option<i64> { 260 match kind { 261 AppActivityKind::SettingsPreferenceUpdated { enabled, .. } => Some(i64::from(*enabled)), 262 _ => None, 263 } 264 } 265 266 #[cfg(test)] 267 mod tests { 268 use radroots_app_view::{AppActivityKind, SettingsPreference, SettingsSection}; 269 use rusqlite::Connection; 270 271 use crate::{AppSqliteStore, DatabaseTarget}; 272 273 use super::{APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository}; 274 275 #[test] 276 fn activity_repository_records_and_loads_typed_recent_events() { 277 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 278 let repository = store.activity_repository(); 279 280 repository 281 .record(&AppActivityKind::HomeOpened) 282 .expect("record home opened"); 283 repository 284 .record(&AppActivityKind::SettingsOpened { 285 section: SettingsSection::Farm, 286 }) 287 .expect("record settings opened"); 288 repository 289 .record(&AppActivityKind::SettingsPreferenceUpdated { 290 preference: SettingsPreference::LaunchAtLogin, 291 enabled: true, 292 }) 293 .expect("record settings preference"); 294 295 let recent = repository.load_recent(8).expect("load recent events"); 296 297 assert_eq!(recent.len(), 3); 298 assert_eq!( 299 recent[0].kind, 300 AppActivityKind::SettingsPreferenceUpdated { 301 preference: SettingsPreference::LaunchAtLogin, 302 enabled: true, 303 } 304 ); 305 assert_eq!( 306 recent[1].kind, 307 AppActivityKind::SettingsOpened { 308 section: SettingsSection::Farm, 309 } 310 ); 311 assert_eq!(recent[2].kind, AppActivityKind::HomeOpened); 312 } 313 314 #[test] 315 fn activity_repository_load_context_uses_default_context_limit() { 316 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 317 let repository = store.activity_repository(); 318 319 repository 320 .record(&AppActivityKind::HomeOpened) 321 .expect("record home opened"); 322 323 let context = repository 324 .load_context(APP_ACTIVITY_CONTEXT_LIMIT) 325 .expect("load activity context"); 326 327 assert_eq!(context.recent_events.len(), 1); 328 assert_eq!(context.recent_events[0].kind, AppActivityKind::HomeOpened); 329 } 330 331 #[test] 332 fn activity_repository_trims_events_to_retention_limit() { 333 let connection = Connection::open_in_memory().expect("open in-memory connection"); 334 connection 335 .execute_batch(include_str!("../../migrations/0001_init.sql")) 336 .expect("apply init migration"); 337 connection 338 .execute_batch(include_str!("../../migrations/0002_activity_journal.sql")) 339 .expect("apply activity migration"); 340 let repository = AppActivityRepository::new(&connection); 341 342 for _ in 0..(APP_ACTIVITY_RETENTION_LIMIT + 8) { 343 repository 344 .record(&AppActivityKind::HomeOpened) 345 .expect("record activity event"); 346 } 347 348 let retained = count_rows(&connection, "activity_events"); 349 350 assert_eq!(retained, APP_ACTIVITY_RETENTION_LIMIT); 351 } 352 353 fn count_rows(connection: &Connection, table_name: &str) -> i64 { 354 let sql = format!("SELECT COUNT(*) FROM {table_name}"); 355 connection 356 .query_row(&sql, [], |row| row.get(0)) 357 .expect("row count query should succeed") 358 } 359 }