Skip to main content

imessage_database/tables/
chat.rs

1/*!
2 Chat table rows and chat metadata helpers.
3*/
4
5use std::collections::HashMap;
6
7use plist::Value;
8use rusqlite::{CachedStatement, Connection, Result, Row};
9
10use crate::{
11    error::{plist::PlistParseError, table::TableError},
12    tables::{
13        messages::models::Service,
14        table::{CHAT, Cacheable, PROPERTIES, Table},
15    },
16    util::plist::{
17        extract_dictionary, extract_string_key, get_bool_from_dict, get_owned_string_from_dict,
18        plist_as_dictionary,
19    },
20};
21
22// MARK: Chat Props
23/// Metadata stored in the `chat.properties` plist.
24#[derive(Debug, PartialEq, Eq)]
25pub struct Properties {
26    /// Whether read receipts are enabled for the chat.
27    pub read_receipts_enabled: bool,
28    /// Most recent message GUID recorded for the chat.
29    pub last_message_guid: Option<String>,
30    /// Whether Messages forced SMS/RCS instead of iMessage.
31    pub forced_sms: bool,
32    /// Group photo attachment GUID.
33    pub group_photo_guid: Option<String>,
34    /// Whether the chat has a custom background.
35    pub has_chat_background: bool,
36}
37
38impl Properties {
39    /// Parse chat properties from a plist value.
40    pub(self) fn from_plist(plist: &Value) -> Result<Self, PlistParseError> {
41        Ok(Self {
42            read_receipts_enabled: get_bool_from_dict(plist, "EnableReadReceiptForChat")
43                .unwrap_or(false),
44            last_message_guid: get_owned_string_from_dict(plist, "lastSeenMessageGuid"),
45            forced_sms: get_bool_from_dict(plist, "shouldForceToSMS").unwrap_or(false),
46            group_photo_guid: get_owned_string_from_dict(plist, "groupPhotoGuid"),
47            has_chat_background: plist_as_dictionary(plist)
48                .and_then(|dict| extract_dictionary(dict, "backgroundProperties"))
49                .and_then(|dict| extract_string_key(dict, "trabar"))
50                .is_ok(),
51        })
52    }
53}
54
55// MARK: Chat Struct
56/// Row from the `chat` table.
57#[derive(Debug)]
58pub struct Chat {
59    /// Chat row ID.
60    pub rowid: i32,
61    /// Phone number, email, or group chat identifier.
62    pub chat_identifier: String,
63    /// Service name stored for the chat.
64    pub service_name: Option<String>,
65    /// User-provided chat display name.
66    pub display_name: Option<String>,
67}
68
69// MARK: Table
70impl Table for Chat {
71    fn from_row(row: &Row) -> Result<Chat> {
72        Ok(Chat {
73            rowid: row.get("rowid")?,
74            chat_identifier: row.get("chat_identifier")?,
75            service_name: row.get("service_name")?,
76            display_name: row.get("display_name").unwrap_or(None),
77        })
78    }
79
80    fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
81        Ok(db.prepare_cached(&format!("SELECT * from {CHAT}"))?)
82    }
83}
84
85// MARK: Cache
86impl Cacheable for Chat {
87    type K = i32;
88    type V = Chat;
89    /// Cache chat rows by row ID.
90    ///
91    /// Chat row IDs can represent duplicate conversations; deduplication happens
92    /// after participant handles are loaded.
93    ///
94    /// # Example:
95    ///
96    /// ```no_run
97    /// use imessage_database::util::dirs::default_db_path;
98    /// use imessage_database::tables::table::{Cacheable, get_connection};
99    /// use imessage_database::tables::chat::Chat;
100    ///
101    /// let db_path = default_db_path();
102    /// let conn = get_connection(&db_path).unwrap();
103    /// let chatrooms = Chat::cache(&conn);
104    /// ```
105    fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
106        let mut map = HashMap::new();
107
108        let mut statement = Chat::get(db)?;
109
110        for chat in Chat::rows(&mut statement, [])? {
111            let result = chat?;
112            map.insert(result.rowid, result);
113        }
114        Ok(map)
115    }
116}
117
118impl Chat {
119    /// Return the display name, falling back to the chat identifier.
120    #[must_use]
121    pub fn name(&self) -> &str {
122        match self.display_name() {
123            Some(name) => name,
124            None => &self.chat_identifier,
125        }
126    }
127
128    /// Return the non-empty custom display name.
129    #[must_use]
130    pub fn display_name(&self) -> Option<&str> {
131        match &self.display_name {
132            Some(name) => {
133                if !name.is_empty() {
134                    return Some(name.as_str());
135                }
136                None
137            }
138            None => None,
139        }
140    }
141
142    /// Return the chat service as a [`Service`].
143    #[must_use]
144    pub fn service(&'_ self) -> Service<'_> {
145        Service::from_name(self.service_name.as_deref())
146    }
147
148    /// Parse [`Properties`] from the chat's plist blob.
149    ///
150    /// Calling this reads a BLOB from the database.
151    #[must_use]
152    pub fn properties(&self, db: &Connection) -> Option<Properties> {
153        match Value::from_reader(self.get_blob(db, CHAT, PROPERTIES, self.rowid.into())?) {
154            Ok(plist) => Properties::from_plist(&plist).ok(),
155            Err(_) => None,
156        }
157    }
158}
159
160// MARK: Tests
161#[cfg(test)]
162mod test_properties {
163    use plist::Value;
164    use std::env::current_dir;
165    use std::fs::File;
166
167    use crate::tables::chat::Properties;
168
169    #[test]
170    fn test_can_parse_properties_simple() {
171        let plist_path = current_dir()
172            .unwrap()
173            .as_path()
174            .join("test_data/chat_properties/ChatProp1.plist");
175        let plist_data = File::open(plist_path).unwrap();
176        let plist = Value::from_reader(plist_data).unwrap();
177        println!("Parsed plist: {plist:#?}");
178
179        let actual = Properties::from_plist(&plist).unwrap();
180        let expected = Properties {
181            read_receipts_enabled: false,
182            last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
183            forced_sms: false,
184            group_photo_guid: None,
185            has_chat_background: false,
186        };
187        print!("Parsed properties: {expected:?}");
188        assert_eq!(actual, expected);
189    }
190
191    #[test]
192    fn test_can_parse_properties_enable_read_receipts() {
193        let plist_path = current_dir()
194            .unwrap()
195            .as_path()
196            .join("test_data/chat_properties/ChatProp2.plist");
197        let plist_data = File::open(plist_path).unwrap();
198        let plist = Value::from_reader(plist_data).unwrap();
199        println!("Parsed plist: {plist:#?}");
200
201        let actual = Properties::from_plist(&plist).unwrap();
202        let expected = Properties {
203            read_receipts_enabled: true,
204            last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
205            forced_sms: false,
206            group_photo_guid: None,
207            has_chat_background: false,
208        };
209        print!("Parsed properties: {expected:?}");
210        assert_eq!(actual, expected);
211    }
212
213    #[test]
214    fn test_can_parse_properties_third_with_summary() {
215        let plist_path = current_dir()
216            .unwrap()
217            .as_path()
218            .join("test_data/chat_properties/ChatProp3.plist");
219        let plist_data = File::open(plist_path).unwrap();
220        let plist = Value::from_reader(plist_data).unwrap();
221        println!("Parsed plist: {plist:#?}");
222
223        let actual = Properties::from_plist(&plist).unwrap();
224        let expected = Properties {
225            read_receipts_enabled: false,
226            last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
227            forced_sms: false,
228            group_photo_guid: None,
229            has_chat_background: false,
230        };
231        print!("Parsed properties: {expected:?}");
232        assert_eq!(actual, expected);
233    }
234
235    #[test]
236    fn test_can_parse_properties_forced_sms() {
237        let plist_path = current_dir()
238            .unwrap()
239            .as_path()
240            .join("test_data/chat_properties/ChatProp4.plist");
241        let plist_data = File::open(plist_path).unwrap();
242        let plist = Value::from_reader(plist_data).unwrap();
243        println!("Parsed plist: {plist:#?}");
244
245        let actual = Properties::from_plist(&plist).unwrap();
246        let expected = Properties {
247            read_receipts_enabled: false,
248            last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
249            forced_sms: true,
250            group_photo_guid: None,
251            has_chat_background: false,
252        };
253        print!("Parsed properties: {expected:?}");
254        assert_eq!(actual, expected);
255    }
256
257    #[test]
258    fn test_can_parse_properties_no_background() {
259        let plist_path = current_dir()
260            .unwrap()
261            .as_path()
262            .join("test_data/chat_properties/before_background.plist");
263        let plist_data = File::open(plist_path).unwrap();
264        let plist = Value::from_reader(plist_data).unwrap();
265        println!("Parsed plist: {plist:#?}");
266
267        let actual = Properties::from_plist(&plist).unwrap();
268        let expected = Properties {
269            read_receipts_enabled: true,
270            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
271            forced_sms: false,
272            group_photo_guid: None,
273            has_chat_background: false,
274        };
275        print!("Parsed properties: {expected:?}");
276        assert_eq!(actual, expected);
277    }
278
279    #[test]
280    fn test_can_parse_properties_added_background() {
281        let plist_path = current_dir()
282            .unwrap()
283            .as_path()
284            .join("test_data/chat_properties/after_background_preset.plist");
285        let plist_data = File::open(plist_path).unwrap();
286        let plist = Value::from_reader(plist_data).unwrap();
287        println!("Parsed plist: {plist:#?}");
288
289        let actual = Properties::from_plist(&plist).unwrap();
290        let expected = Properties {
291            read_receipts_enabled: true,
292            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
293            forced_sms: false,
294            group_photo_guid: None,
295            has_chat_background: true,
296        };
297        print!("Parsed properties: {expected:?}");
298        assert_eq!(actual, expected);
299    }
300
301    #[test]
302    fn test_can_parse_properties_removed_background() {
303        let plist_path = current_dir()
304            .unwrap()
305            .as_path()
306            .join("test_data/chat_properties/after_background_removed.plist");
307        let plist_data = File::open(plist_path).unwrap();
308        let plist = Value::from_reader(plist_data).unwrap();
309        println!("Parsed plist: {plist:#?}");
310
311        let actual = Properties::from_plist(&plist).unwrap();
312        let expected = Properties {
313            read_receipts_enabled: true,
314            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
315            forced_sms: false,
316            group_photo_guid: None,
317            has_chat_background: false,
318        };
319        print!("Parsed properties: {expected:?}");
320        assert_eq!(actual, expected);
321    }
322}