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