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    /// ```
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        let chats = statement.query_map([], |row| Ok(Chat::from_row(row)))?;
112
113        for chat in chats {
114            let result = Chat::extract(chat)?;
115            map.insert(result.rowid, result);
116        }
117        Ok(map)
118    }
119}
120
121impl Chat {
122    /// Generate a name for a chat, falling back to the default if a custom one is not set
123    #[must_use]
124    pub fn name(&self) -> &str {
125        match self.display_name() {
126            Some(name) => name,
127            None => &self.chat_identifier,
128        }
129    }
130
131    /// Get the current display name for the chat, if it exists.
132    #[must_use]
133    pub fn display_name(&self) -> Option<&str> {
134        match &self.display_name {
135            Some(name) => {
136                if !name.is_empty() {
137                    return Some(name.as_str());
138                }
139                None
140            }
141            None => None,
142        }
143    }
144
145    /// Get the service used by the chat, i.e. iMessage, SMS, IRC, etc.
146    #[must_use]
147    pub fn service(&'_ self) -> Service<'_> {
148        Service::from_name(self.service_name.as_deref())
149    }
150
151    /// Get the [`Properties`] for the chat, if they exist
152    ///
153    /// Calling this hits the database, so it is expensive and should
154    /// only get invoked when needed.
155    #[must_use]
156    pub fn properties(&self, db: &Connection) -> Option<Properties> {
157        match Value::from_reader(self.get_blob(db, CHAT, PROPERTIES, self.rowid.into())?) {
158            Ok(plist) => Properties::from_plist(&plist).ok(),
159            Err(_) => None,
160        }
161    }
162}
163
164// MARK: Tests
165#[cfg(test)]
166mod test_properties {
167    use plist::Value;
168    use std::env::current_dir;
169    use std::fs::File;
170
171    use crate::tables::chat::Properties;
172
173    #[test]
174    fn test_can_parse_properties_simple() {
175        let plist_path = current_dir()
176            .unwrap()
177            .as_path()
178            .join("test_data/chat_properties/ChatProp1.plist");
179        let plist_data = File::open(plist_path).unwrap();
180        let plist = Value::from_reader(plist_data).unwrap();
181        println!("Parsed plist: {plist:#?}");
182
183        let actual = Properties::from_plist(&plist).unwrap();
184        let expected = Properties {
185            read_receipts_enabled: false,
186            last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
187            forced_sms: false,
188            group_photo_guid: None,
189            has_chat_background: false,
190        };
191        print!("Parsed properties: {expected:?}");
192        assert_eq!(actual, expected);
193    }
194
195    #[test]
196    fn test_can_parse_properties_enable_read_receipts() {
197        let plist_path = current_dir()
198            .unwrap()
199            .as_path()
200            .join("test_data/chat_properties/ChatProp2.plist");
201        let plist_data = File::open(plist_path).unwrap();
202        let plist = Value::from_reader(plist_data).unwrap();
203        println!("Parsed plist: {plist:#?}");
204
205        let actual = Properties::from_plist(&plist).unwrap();
206        let expected = Properties {
207            read_receipts_enabled: true,
208            last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
209            forced_sms: false,
210            group_photo_guid: None,
211            has_chat_background: false,
212        };
213        print!("Parsed properties: {expected:?}");
214        assert_eq!(actual, expected);
215    }
216
217    #[test]
218    fn test_can_parse_properties_third_with_summary() {
219        let plist_path = current_dir()
220            .unwrap()
221            .as_path()
222            .join("test_data/chat_properties/ChatProp3.plist");
223        let plist_data = File::open(plist_path).unwrap();
224        let plist = Value::from_reader(plist_data).unwrap();
225        println!("Parsed plist: {plist:#?}");
226
227        let actual = Properties::from_plist(&plist).unwrap();
228        let expected = Properties {
229            read_receipts_enabled: false,
230            last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
231            forced_sms: false,
232            group_photo_guid: None,
233            has_chat_background: false,
234        };
235        print!("Parsed properties: {expected:?}");
236        assert_eq!(actual, expected);
237    }
238
239    #[test]
240    fn test_can_parse_properties_forced_sms() {
241        let plist_path = current_dir()
242            .unwrap()
243            .as_path()
244            .join("test_data/chat_properties/ChatProp4.plist");
245        let plist_data = File::open(plist_path).unwrap();
246        let plist = Value::from_reader(plist_data).unwrap();
247        println!("Parsed plist: {plist:#?}");
248
249        let actual = Properties::from_plist(&plist).unwrap();
250        let expected = Properties {
251            read_receipts_enabled: false,
252            last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
253            forced_sms: true,
254            group_photo_guid: None,
255            has_chat_background: false,
256        };
257        print!("Parsed properties: {expected:?}");
258        assert_eq!(actual, expected);
259    }
260
261    #[test]
262    fn test_can_parse_properties_no_background() {
263        let plist_path = current_dir()
264            .unwrap()
265            .as_path()
266            .join("test_data/chat_properties/before_background.plist");
267        let plist_data = File::open(plist_path).unwrap();
268        let plist = Value::from_reader(plist_data).unwrap();
269        println!("Parsed plist: {plist:#?}");
270
271        let actual = Properties::from_plist(&plist).unwrap();
272        let expected = Properties {
273            read_receipts_enabled: true,
274            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
275            forced_sms: false,
276            group_photo_guid: None,
277            has_chat_background: false,
278        };
279        print!("Parsed properties: {expected:?}");
280        assert_eq!(actual, expected);
281    }
282
283    #[test]
284    fn test_can_parse_properties_added_background() {
285        let plist_path = current_dir()
286            .unwrap()
287            .as_path()
288            .join("test_data/chat_properties/after_background_preset.plist");
289        let plist_data = File::open(plist_path).unwrap();
290        let plist = Value::from_reader(plist_data).unwrap();
291        println!("Parsed plist: {plist:#?}");
292
293        let actual = Properties::from_plist(&plist).unwrap();
294        let expected = Properties {
295            read_receipts_enabled: true,
296            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
297            forced_sms: false,
298            group_photo_guid: None,
299            has_chat_background: true,
300        };
301        print!("Parsed properties: {expected:?}");
302        assert_eq!(actual, expected);
303    }
304
305    #[test]
306    fn test_can_parse_properties_removed_background() {
307        let plist_path = current_dir()
308            .unwrap()
309            .as_path()
310            .join("test_data/chat_properties/after_background_removed.plist");
311        let plist_data = File::open(plist_path).unwrap();
312        let plist = Value::from_reader(plist_data).unwrap();
313        println!("Parsed plist: {plist:#?}");
314
315        let actual = Properties::from_plist(&plist).unwrap();
316        let expected = Properties {
317            read_receipts_enabled: true,
318            last_message_guid: Some(String::from("49DA49E8-0000-0000-B59E-290294670E7D")),
319            forced_sms: false,
320            group_photo_guid: None,
321            has_chat_background: false,
322        };
323        print!("Parsed properties: {expected:?}");
324        assert_eq!(actual, expected);
325    }
326}