Skip to main content

imessage_database/tables/
chat.rs

1/*!
2 Chat table rows and chat metadata helpers.
3*/
4
5use 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// MARK: Chat Props
24/// Metadata stored in the `chat.properties` plist.
25#[derive(Debug, PartialEq, Eq)]
26pub struct Properties {
27    /// Whether read receipts are enabled for the chat.
28    pub read_receipts_enabled: bool,
29    /// Most recent message GUID recorded for the chat.
30    pub last_message_guid: Option<String>,
31    /// Whether Messages forced SMS/RCS instead of iMessage.
32    pub forced_sms: bool,
33    /// Group photo attachment GUID.
34    pub group_photo_guid: Option<String>,
35    /// Whether the chat has a custom background.
36    pub has_chat_background: bool,
37}
38
39impl Properties {
40    /// Parse chat properties from a plist value.
41    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// MARK: Chat Filter Status
57/// Conversation-list tier stored in `chat.is_filtered`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ChatFilterStatus {
60    /// Main conversation list (`0`).
61    Unfiltered,
62    /// Unknown Senders bucket (`1`).
63    UnknownSenders,
64    /// Junk bucket (`2`).
65    Junk,
66    /// Unrecognized raw value.
67    Unknown(i32),
68}
69
70impl ChatFilterStatus {
71    /// Map a raw `is_filtered` value to its conversation-list tier.
72    ///
73    /// Preserve a missing value as `None` and an unrecognized value as
74    /// [`Self::Unknown`].
75    #[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    /// Return whether the status is outside the main conversation list.
86    ///
87    /// Every variant except [`Self::Unfiltered`] is filtered, including
88    /// [`Self::Unknown`].
89    #[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// MARK: Chat Struct
105/// Row from the `chat` table.
106#[derive(Debug)]
107pub struct Chat {
108    /// Chat row ID.
109    pub rowid: i32,
110    /// Phone number, email, or group chat identifier.
111    pub chat_identifier: String,
112    /// Service name stored for the chat.
113    pub service_name: Option<String>,
114    /// User-provided chat display name.
115    pub display_name: Option<String>,
116    /// Raw conversation-list tier from `chat.is_filtered`, used to build
117    /// the [`ChatFilterStatus`] via [`Self::filter_status`].
118    pub is_filtered: Option<i32>,
119    /// Raw `chat.is_blackholed` flag: `1` denotes a chat whose incoming
120    /// messages are silently dropped.
121    pub is_blackholed: Option<bool>,
122    /// Raw `chat.is_pending_review` flag: `1` denotes a filtered chat awaiting
123    /// review.
124    pub is_pending_review: Option<bool>,
125}
126
127// MARK: Table
128impl 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
146// MARK: Cache
147impl Cacheable for Chat {
148    type K = i32;
149    type V = Chat;
150    /// Cache chat rows by row ID.
151    ///
152    /// Chat row IDs can represent duplicate conversations; deduplication happens
153    /// after participant handles are loaded.
154    ///
155    /// # Example:
156    ///
157    /// ```no_run
158    /// use imessage_database::util::dirs::default_db_path;
159    /// use imessage_database::tables::table::{Cacheable, get_connection};
160    /// use imessage_database::tables::chat::Chat;
161    ///
162    /// let db_path = default_db_path();
163    /// let conn = get_connection(&db_path).unwrap();
164    /// let chatrooms = Chat::cache(&conn);
165    /// ```
166    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    /// Return the display name, falling back to the chat identifier.
181    #[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    /// Return the non-empty custom display name.
190    #[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    /// Return the chat service as a [`Service`].
204    #[must_use]
205    pub fn service(&'_ self) -> Service<'_> {
206        Service::from_name(self.service_name.as_deref())
207    }
208
209    /// Parse the conversation-list tier from [`Self::is_filtered`].
210    ///
211    /// A raw `0` maps to [`ChatFilterStatus::Unfiltered`]. `None` remains
212    /// `None`, and every unrecognized value maps to [`ChatFilterStatus::Unknown`].
213    #[must_use]
214    pub fn filter_status(&self) -> Option<ChatFilterStatus> {
215        ChatFilterStatus::from_code(self.is_filtered)
216    }
217
218    /// Parse [`Properties`] from the chat's plist blob.
219    ///
220    /// Calling this reads a BLOB from the database.
221    #[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// MARK: Tests
231#[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    /// Build a minimal in-memory `chat` table, optionally including filter-state columns.
289    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}