1use std::collections::HashMap;
6use std::fmt::{Display, Formatter};
7
8use plist::Value;
9use rusqlite::{CachedStatement, Connection, Result, Row};
10
11use crate::{
12 error::{plist::PlistParseError, table::TableError},
13 tables::{
14 messages::models::Service,
15 table::{CHAT, Cacheable, PROPERTIES, Table},
16 },
17 util::plist::{
18 extract_dictionary, extract_string_key, get_bool_from_dict, get_owned_string_from_dict,
19 plist_as_dictionary,
20 },
21};
22
23#[derive(Debug, PartialEq, Eq)]
26pub struct Properties {
27 pub read_receipts_enabled: bool,
29 pub last_message_guid: Option<String>,
31 pub forced_sms: bool,
33 pub group_photo_guid: Option<String>,
35 pub has_chat_background: bool,
37}
38
39impl Properties {
40 pub(self) fn from_plist(plist: &Value) -> Result<Self, PlistParseError> {
42 Ok(Self {
43 read_receipts_enabled: get_bool_from_dict(plist, "EnableReadReceiptForChat")
44 .unwrap_or(false),
45 last_message_guid: get_owned_string_from_dict(plist, "lastSeenMessageGuid"),
46 forced_sms: get_bool_from_dict(plist, "shouldForceToSMS").unwrap_or(false),
47 group_photo_guid: get_owned_string_from_dict(plist, "groupPhotoGuid"),
48 has_chat_background: plist_as_dictionary(plist)
49 .and_then(|dict| extract_dictionary(dict, "backgroundProperties"))
50 .and_then(|dict| extract_string_key(dict, "trabar"))
51 .is_ok(),
52 })
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ChatFilterStatus {
60 Unfiltered,
62 UnknownSenders,
64 Junk,
66 Unknown(i32),
68}
69
70impl ChatFilterStatus {
71 #[must_use]
76 pub fn from_code(code: Option<i32>) -> Option<Self> {
77 Some(match code? {
78 0 => Self::Unfiltered,
79 1 => Self::UnknownSenders,
80 2 => Self::Junk,
81 other => Self::Unknown(other),
82 })
83 }
84
85 #[must_use]
90 pub fn is_filtered(&self) -> bool {
91 !matches!(self, Self::Unfiltered)
92 }
93}
94
95impl Display for ChatFilterStatus {
96 fn fmt(&self, fmt: &mut Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Self::Unknown(code) => write!(fmt, "Unknown ({code})"),
99 _ => write!(fmt, "{self:?}"),
100 }
101 }
102}
103
104#[derive(Debug)]
107pub struct Chat {
108 pub rowid: i32,
110 pub chat_identifier: String,
112 pub service_name: Option<String>,
114 pub display_name: Option<String>,
116 pub is_filtered: Option<i32>,
119 pub is_blackholed: Option<bool>,
122 pub is_pending_review: Option<bool>,
125}
126
127impl Table for Chat {
129 fn from_row(row: &Row) -> Result<Chat> {
130 Ok(Chat {
131 rowid: row.get("rowid")?,
132 chat_identifier: row.get("chat_identifier")?,
133 service_name: row.get("service_name")?,
134 display_name: row.get("display_name").unwrap_or(None),
135 is_filtered: row.get("is_filtered").unwrap_or(None),
136 is_blackholed: row.get("is_blackholed").unwrap_or(None),
137 is_pending_review: row.get("is_pending_review").unwrap_or(None),
138 })
139 }
140
141 fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
142 Ok(db.prepare_cached(&format!("SELECT * from {CHAT}"))?)
143 }
144}
145
146impl Cacheable for Chat {
148 type K = i32;
149 type V = Chat;
150 fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
167 let mut map = HashMap::new();
168
169 let mut statement = Chat::get(db)?;
170
171 for chat in Chat::rows(&mut statement, [])? {
172 let result = chat?;
173 map.insert(result.rowid, result);
174 }
175 Ok(map)
176 }
177}
178
179impl Chat {
180 #[must_use]
182 pub fn name(&self) -> &str {
183 match self.display_name() {
184 Some(name) => name,
185 None => &self.chat_identifier,
186 }
187 }
188
189 #[must_use]
191 pub fn display_name(&self) -> Option<&str> {
192 match &self.display_name {
193 Some(name) => {
194 if !name.is_empty() {
195 return Some(name.as_str());
196 }
197 None
198 }
199 None => None,
200 }
201 }
202
203 #[must_use]
205 pub fn service(&'_ self) -> Service<'_> {
206 Service::from_name(self.service_name.as_deref())
207 }
208
209 #[must_use]
214 pub fn filter_status(&self) -> Option<ChatFilterStatus> {
215 ChatFilterStatus::from_code(self.is_filtered)
216 }
217
218 #[must_use]
222 pub fn properties(&self, db: &Connection) -> Option<Properties> {
223 match Value::from_reader(self.get_blob(db, CHAT, PROPERTIES, self.rowid.into())?) {
224 Ok(plist) => Properties::from_plist(&plist).ok(),
225 Err(_) => None,
226 }
227 }
228}
229
230#[cfg(test)]
232mod test_filter_status {
233 use crate::tables::chat::ChatFilterStatus;
234
235 #[test]
236 fn maps_known_codes() {
237 assert_eq!(
238 ChatFilterStatus::from_code(Some(0)),
239 Some(ChatFilterStatus::Unfiltered)
240 );
241 assert_eq!(
242 ChatFilterStatus::from_code(Some(1)),
243 Some(ChatFilterStatus::UnknownSenders)
244 );
245 assert_eq!(
246 ChatFilterStatus::from_code(Some(2)),
247 Some(ChatFilterStatus::Junk)
248 );
249 }
250
251 #[test]
252 fn preserves_unrecognized_code() {
253 assert_eq!(
254 ChatFilterStatus::from_code(Some(7)),
255 Some(ChatFilterStatus::Unknown(7))
256 );
257 }
258
259 #[test]
260 fn missing_value_is_none() {
261 assert_eq!(ChatFilterStatus::from_code(None), None);
262 }
263
264 #[test]
265 fn is_filtered_covers_every_nonzero_tier() {
266 assert!(!ChatFilterStatus::Unfiltered.is_filtered());
267 assert!(ChatFilterStatus::UnknownSenders.is_filtered());
268 assert!(ChatFilterStatus::Junk.is_filtered());
269 assert!(ChatFilterStatus::Unknown(7).is_filtered());
270 }
271
272 #[test]
273 fn display_names_the_unknown_code() {
274 assert_eq!(ChatFilterStatus::Junk.to_string(), "Junk");
275 assert_eq!(ChatFilterStatus::Unknown(7).to_string(), "Unknown (7)");
276 }
277}
278
279#[cfg(test)]
280mod test_from_row {
281 use rusqlite::Connection;
282
283 use crate::tables::{
284 chat::{Chat, ChatFilterStatus},
285 table::Table,
286 };
287
288 fn chat_db(with_filter_columns: bool) -> Connection {
290 let filter_columns = if with_filter_columns {
291 ",
292 is_filtered INTEGER DEFAULT 0,
293 is_blackholed INTEGER DEFAULT 0,
294 is_pending_review INTEGER DEFAULT 0"
295 } else {
296 ""
297 };
298 let db = Connection::open_in_memory().unwrap();
299 db.execute_batch(&format!(
300 "CREATE TABLE chat (
301 ROWID INTEGER PRIMARY KEY,
302 chat_identifier TEXT,
303 service_name TEXT,
304 display_name TEXT{filter_columns}
305 );"
306 ))
307 .unwrap();
308 db
309 }
310
311 fn all_chats(db: &Connection) -> Vec<Chat> {
312 let mut statement = Chat::get(db).unwrap();
313 Chat::rows(&mut statement, [])
314 .unwrap()
315 .collect::<Result<Vec<_>, _>>()
316 .unwrap()
317 }
318
319 #[test]
320 fn reads_filter_state_codes() {
321 let db = chat_db(true);
322 db.execute_batch(
323 "INSERT INTO chat (ROWID, chat_identifier, is_filtered, is_blackholed, is_pending_review) VALUES
324 (1, 'a', 0, 0, 0),
325 (2, 'b', 1, 1, 1),
326 (3, 'c', NULL, NULL, NULL);",
327 )
328 .unwrap();
329
330 let chats = all_chats(&db);
331 assert_eq!(chats[0].is_filtered, Some(0));
332 assert_eq!(chats[0].filter_status(), Some(ChatFilterStatus::Unfiltered));
333 assert_eq!(chats[0].is_blackholed, Some(false));
334 assert_eq!(chats[0].is_pending_review, Some(false));
335 assert_eq!(chats[1].is_filtered, Some(1));
336 assert_eq!(
337 chats[1].filter_status(),
338 Some(ChatFilterStatus::UnknownSenders)
339 );
340 assert_eq!(chats[1].is_blackholed, Some(true));
341 assert_eq!(chats[1].is_pending_review, Some(true));
342 assert_eq!(chats[2].is_filtered, None);
343 assert_eq!(chats[2].filter_status(), None);
344 assert_eq!(chats[2].is_blackholed, None);
345 assert_eq!(chats[2].is_pending_review, None);
346 }
347
348 #[test]
349 fn schema_without_filter_columns_reads_none() {
350 let db = chat_db(false);
351 db.execute_batch("INSERT INTO chat (ROWID, chat_identifier) VALUES (1, 'a');")
352 .unwrap();
353
354 let chats = all_chats(&db);
355 assert_eq!(chats[0].is_filtered, None);
356 assert_eq!(chats[0].is_blackholed, None);
357 assert_eq!(chats[0].is_pending_review, None);
358 }
359}
360
361#[cfg(test)]
362mod test_properties {
363 use plist::Value;
364 use std::env::current_dir;
365 use std::fs::File;
366
367 use crate::tables::chat::Properties;
368
369 #[test]
370 fn test_can_parse_properties_simple() {
371 let plist_path = current_dir()
372 .unwrap()
373 .as_path()
374 .join("test_data/chat_properties/ChatProp1.plist");
375 let plist_data = File::open(plist_path).unwrap();
376 let plist = Value::from_reader(plist_data).unwrap();
377 println!("Parsed plist: {plist:#?}");
378
379 let actual = Properties::from_plist(&plist).unwrap();
380 let expected = Properties {
381 read_receipts_enabled: false,
382 last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
383 forced_sms: false,
384 group_photo_guid: None,
385 has_chat_background: false,
386 };
387 print!("Parsed properties: {expected:?}");
388 assert_eq!(actual, expected);
389 }
390
391 #[test]
392 fn test_can_parse_properties_enable_read_receipts() {
393 let plist_path = current_dir()
394 .unwrap()
395 .as_path()
396 .join("test_data/chat_properties/ChatProp2.plist");
397 let plist_data = File::open(plist_path).unwrap();
398 let plist = Value::from_reader(plist_data).unwrap();
399 println!("Parsed plist: {plist:#?}");
400
401 let actual = Properties::from_plist(&plist).unwrap();
402 let expected = Properties {
403 read_receipts_enabled: true,
404 last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
405 forced_sms: false,
406 group_photo_guid: None,
407 has_chat_background: false,
408 };
409 print!("Parsed properties: {expected:?}");
410 assert_eq!(actual, expected);
411 }
412
413 #[test]
414 fn test_can_parse_properties_third_with_summary() {
415 let plist_path = current_dir()
416 .unwrap()
417 .as_path()
418 .join("test_data/chat_properties/ChatProp3.plist");
419 let plist_data = File::open(plist_path).unwrap();
420 let plist = Value::from_reader(plist_data).unwrap();
421 println!("Parsed plist: {plist:#?}");
422
423 let actual = Properties::from_plist(&plist).unwrap();
424 let expected = Properties {
425 read_receipts_enabled: false,
426 last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
427 forced_sms: false,
428 group_photo_guid: None,
429 has_chat_background: false,
430 };
431 print!("Parsed properties: {expected:?}");
432 assert_eq!(actual, expected);
433 }
434
435 #[test]
436 fn test_can_parse_properties_forced_sms() {
437 let plist_path = current_dir()
438 .unwrap()
439 .as_path()
440 .join("test_data/chat_properties/ChatProp4.plist");
441 let plist_data = File::open(plist_path).unwrap();
442 let plist = Value::from_reader(plist_data).unwrap();
443 println!("Parsed plist: {plist:#?}");
444
445 let actual = Properties::from_plist(&plist).unwrap();
446 let expected = Properties {
447 read_receipts_enabled: false,
448 last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
449 forced_sms: true,
450 group_photo_guid: None,
451 has_chat_background: false,
452 };
453 print!("Parsed properties: {expected:?}");
454 assert_eq!(actual, expected);
455 }
456
457 #[test]
458 fn test_can_parse_properties_no_background() {
459 let plist_path = current_dir()
460 .unwrap()
461 .as_path()
462 .join("test_data/chat_properties/before_background.plist");
463 let plist_data = File::open(plist_path).unwrap();
464 let plist = Value::from_reader(plist_data).unwrap();
465 println!("Parsed plist: {plist:#?}");
466
467 let actual = Properties::from_plist(&plist).unwrap();
468 let expected = Properties {
469 read_receipts_enabled: true,
470 last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
471 forced_sms: false,
472 group_photo_guid: None,
473 has_chat_background: false,
474 };
475 print!("Parsed properties: {expected:?}");
476 assert_eq!(actual, expected);
477 }
478
479 #[test]
480 fn test_can_parse_properties_added_background() {
481 let plist_path = current_dir()
482 .unwrap()
483 .as_path()
484 .join("test_data/chat_properties/after_background_preset.plist");
485 let plist_data = File::open(plist_path).unwrap();
486 let plist = Value::from_reader(plist_data).unwrap();
487 println!("Parsed plist: {plist:#?}");
488
489 let actual = Properties::from_plist(&plist).unwrap();
490 let expected = Properties {
491 read_receipts_enabled: true,
492 last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
493 forced_sms: false,
494 group_photo_guid: None,
495 has_chat_background: true,
496 };
497 print!("Parsed properties: {expected:?}");
498 assert_eq!(actual, expected);
499 }
500
501 #[test]
502 fn test_can_parse_properties_removed_background() {
503 let plist_path = current_dir()
504 .unwrap()
505 .as_path()
506 .join("test_data/chat_properties/after_background_removed.plist");
507 let plist_data = File::open(plist_path).unwrap();
508 let plist = Value::from_reader(plist_data).unwrap();
509 println!("Parsed plist: {plist:#?}");
510
511 let actual = Properties::from_plist(&plist).unwrap();
512 let expected = Properties {
513 read_receipts_enabled: true,
514 last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
515 forced_sms: false,
516 group_photo_guid: None,
517 has_chat_background: false,
518 };
519 print!("Parsed properties: {expected:?}");
520 assert_eq!(actual, expected);
521 }
522}