Skip to main content

imessage_database/tables/
chat.rs

1/*!
2 This module represents common (but not all) columns in the `chat` table.
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/// Chat properties are stored as a `plist` in the database
24/// This represents the metadata for a chatroom
25#[derive(Debug, PartialEq, Eq)]
26pub struct Properties {
27    /// Whether the chat has read receipts enabled
28    pub read_receipts_enabled: bool,
29    /// The most recent message in the chat
30    pub last_message_guid: Option<String>,
31    /// Whether the chat was forced to use SMS/RCS instead of iMessage
32    pub forced_sms: bool,
33    /// GUID of the group photo, if it exists in the attachments table
34    pub group_photo_guid: Option<String>,
35    /// Whether the chat has a custom background image
36    pub has_chat_background: bool,
37}
38
39impl Properties {
40    /// Create a new `Properties` given a `plist` blob
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 Struct
57/// Represents a single row in the `chat` table.
58#[derive(Debug)]
59pub struct Chat {
60    /// The unique identifier for the chat in the database
61    pub rowid: i32,
62    /// The identifier for the chat, typically a phone number, email, or group chat ID
63    pub chat_identifier: String,
64    /// The service the chat used, i.e. iMessage, SMS, IRC, etc.
65    pub service_name: Option<String>,
66    /// Optional custom name created for the chat
67    pub display_name: Option<String>,
68}
69
70// MARK: Table
71impl Table for Chat {
72    fn from_row(row: &Row) -> Result<Chat> {
73        Ok(Chat {
74            rowid: row.get("rowid")?,
75            chat_identifier: row.get("chat_identifier")?,
76            service_name: row.get("service_name")?,
77            display_name: row.get("display_name").unwrap_or(None),
78        })
79    }
80
81    fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
82        Ok(db.prepare_cached(&format!("SELECT * from {CHAT}"))?)
83    }
84}
85
86// MARK: Cache
87impl Cacheable for Chat {
88    type K = i32;
89    type V = Chat;
90    /// Generate a hashmap containing each chatroom's ID pointing to the chatroom's metadata.
91    ///
92    /// These chatroom ID's contain duplicates and must be deduped later once we have all of
93    /// the participants parsed out. On its own this data is not useful.
94    ///
95    /// # Example:
96    ///
97    /// ```no_run
98    /// use imessage_database::util::dirs::default_db_path;
99    /// use imessage_database::tables::table::{Cacheable, get_connection};
100    /// use imessage_database::tables::chat::Chat;
101    ///
102    /// let db_path = default_db_path();
103    /// let conn = get_connection(&db_path).unwrap();
104    /// let chatrooms = Chat::cache(&conn);
105    /// ```
106    fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
107        let mut map = HashMap::new();
108
109        let mut statement = Chat::get(db)?;
110
111        for chat in Chat::rows(&mut statement, [])? {
112            let result = chat?;
113            map.insert(result.rowid, result);
114        }
115        Ok(map)
116    }
117}
118
119impl Chat {
120    /// Generate a name for a chat, falling back to the default if a custom one is not set
121    #[must_use]
122    pub fn name(&self) -> &str {
123        match self.display_name() {
124            Some(name) => name,
125            None => &self.chat_identifier,
126        }
127    }
128
129    /// Get the current display name for the chat, if it exists.
130    #[must_use]
131    pub fn display_name(&self) -> Option<&str> {
132        match &self.display_name {
133            Some(name) => {
134                if !name.is_empty() {
135                    return Some(name.as_str());
136                }
137                None
138            }
139            None => None,
140        }
141    }
142
143    /// Get the service used by the chat, i.e. iMessage, SMS, IRC, etc.
144    #[must_use]
145    pub fn service(&'_ self) -> Service<'_> {
146        Service::from_name(self.service_name.as_deref())
147    }
148
149    /// Get the [`Properties`] for the chat, if they exist
150    ///
151    /// Calling this hits the database, so it is expensive and should
152    /// only get invoked when needed.
153    #[must_use]
154    pub fn properties(&self, db: &Connection) -> Option<Properties> {
155        match Value::from_reader(self.get_blob(db, CHAT, PROPERTIES, self.rowid.into())?) {
156            Ok(plist) => Properties::from_plist(&plist).ok(),
157            Err(_) => None,
158        }
159    }
160}
161
162// MARK: Tests
163#[cfg(test)]
164mod test_properties {
165    use plist::Value;
166    use std::env::current_dir;
167    use std::fs::File;
168
169    use crate::tables::chat::Properties;
170
171    #[test]
172    fn test_can_parse_properties_simple() {
173        let plist_path = current_dir()
174            .unwrap()
175            .as_path()
176            .join("test_data/chat_properties/ChatProp1.plist");
177        let plist_data = File::open(plist_path).unwrap();
178        let plist = Value::from_reader(plist_data).unwrap();
179        println!("Parsed plist: {plist:#?}");
180
181        let actual = Properties::from_plist(&plist).unwrap();
182        let expected = Properties {
183            read_receipts_enabled: false,
184            last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
185            forced_sms: false,
186            group_photo_guid: None,
187            has_chat_background: false,
188        };
189        print!("Parsed properties: {expected:?}");
190        assert_eq!(actual, expected);
191    }
192
193    #[test]
194    fn test_can_parse_properties_enable_read_receipts() {
195        let plist_path = current_dir()
196            .unwrap()
197            .as_path()
198            .join("test_data/chat_properties/ChatProp2.plist");
199        let plist_data = File::open(plist_path).unwrap();
200        let plist = Value::from_reader(plist_data).unwrap();
201        println!("Parsed plist: {plist:#?}");
202
203        let actual = Properties::from_plist(&plist).unwrap();
204        let expected = Properties {
205            read_receipts_enabled: true,
206            last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
207            forced_sms: false,
208            group_photo_guid: None,
209            has_chat_background: false,
210        };
211        print!("Parsed properties: {expected:?}");
212        assert_eq!(actual, expected);
213    }
214
215    #[test]
216    fn test_can_parse_properties_third_with_summary() {
217        let plist_path = current_dir()
218            .unwrap()
219            .as_path()
220            .join("test_data/chat_properties/ChatProp3.plist");
221        let plist_data = File::open(plist_path).unwrap();
222        let plist = Value::from_reader(plist_data).unwrap();
223        println!("Parsed plist: {plist:#?}");
224
225        let actual = Properties::from_plist(&plist).unwrap();
226        let expected = Properties {
227            read_receipts_enabled: false,
228            last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
229            forced_sms: false,
230            group_photo_guid: None,
231            has_chat_background: false,
232        };
233        print!("Parsed properties: {expected:?}");
234        assert_eq!(actual, expected);
235    }
236
237    #[test]
238    fn test_can_parse_properties_forced_sms() {
239        let plist_path = current_dir()
240            .unwrap()
241            .as_path()
242            .join("test_data/chat_properties/ChatProp4.plist");
243        let plist_data = File::open(plist_path).unwrap();
244        let plist = Value::from_reader(plist_data).unwrap();
245        println!("Parsed plist: {plist:#?}");
246
247        let actual = Properties::from_plist(&plist).unwrap();
248        let expected = Properties {
249            read_receipts_enabled: false,
250            last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
251            forced_sms: true,
252            group_photo_guid: None,
253            has_chat_background: false,
254        };
255        print!("Parsed properties: {expected:?}");
256        assert_eq!(actual, expected);
257    }
258
259    #[test]
260    fn test_can_parse_properties_no_background() {
261        let plist_path = current_dir()
262            .unwrap()
263            .as_path()
264            .join("test_data/chat_properties/before_background.plist");
265        let plist_data = File::open(plist_path).unwrap();
266        let plist = Value::from_reader(plist_data).unwrap();
267        println!("Parsed plist: {plist:#?}");
268
269        let actual = Properties::from_plist(&plist).unwrap();
270        let expected = Properties {
271            read_receipts_enabled: true,
272            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
273            forced_sms: false,
274            group_photo_guid: None,
275            has_chat_background: false,
276        };
277        print!("Parsed properties: {expected:?}");
278        assert_eq!(actual, expected);
279    }
280
281    #[test]
282    fn test_can_parse_properties_added_background() {
283        let plist_path = current_dir()
284            .unwrap()
285            .as_path()
286            .join("test_data/chat_properties/after_background_preset.plist");
287        let plist_data = File::open(plist_path).unwrap();
288        let plist = Value::from_reader(plist_data).unwrap();
289        println!("Parsed plist: {plist:#?}");
290
291        let actual = Properties::from_plist(&plist).unwrap();
292        let expected = Properties {
293            read_receipts_enabled: true,
294            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
295            forced_sms: false,
296            group_photo_guid: None,
297            has_chat_background: true,
298        };
299        print!("Parsed properties: {expected:?}");
300        assert_eq!(actual, expected);
301    }
302
303    #[test]
304    fn test_can_parse_properties_removed_background() {
305        let plist_path = current_dir()
306            .unwrap()
307            .as_path()
308            .join("test_data/chat_properties/after_background_removed.plist");
309        let plist_data = File::open(plist_path).unwrap();
310        let plist = Value::from_reader(plist_data).unwrap();
311        println!("Parsed plist: {plist:#?}");
312
313        let actual = Properties::from_plist(&plist).unwrap();
314        let expected = Properties {
315            read_receipts_enabled: true,
316            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
317            forced_sms: false,
318            group_photo_guid: None,
319            has_chat_background: false,
320        };
321        print!("Parsed properties: {expected:?}");
322        assert_eq!(actual, expected);
323    }
324}