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::{get_bool_from_dict, get_owned_string_from_dict},
17};
18
19/// Chat properties are stored as a `plist` in the database
20/// This represents the metadata for a chatroom
21#[derive(Debug, PartialEq, Eq)]
22pub struct Properties {
23    /// Whether the chat has read receipts enabled
24    read_receipts_enabled: bool,
25    /// The most recent message in the chat
26    last_message_guid: Option<String>,
27    /// Whether the chat was forced to use SMS/RCS instead of iMessage
28    forced_sms: bool,
29    /// GUID of the group photo, if it exists in the attachments table
30    group_photo_guid: Option<String>,
31}
32
33impl Properties {
34    /// Create a new `Properties` given a `plist` blob
35    pub(self) fn from_plist(plist: &Value) -> Result<Self, PlistParseError> {
36        Ok(Self {
37            read_receipts_enabled: get_bool_from_dict(plist, "EnableReadReceiptForChat")
38                .unwrap_or(false),
39            last_message_guid: get_owned_string_from_dict(plist, "lastSeenMessageGuid"),
40            forced_sms: get_bool_from_dict(plist, "shouldForceToSMS").unwrap_or(false),
41            group_photo_guid: get_owned_string_from_dict(plist, "groupPhotoGuid"),
42        })
43    }
44}
45
46/// Represents a single row in the `chat` table.
47#[derive(Debug)]
48pub struct Chat {
49    /// The unique identifier for the chat in the database
50    pub rowid: i32,
51    /// The identifier for the chat, typically a phone number, email, or group chat ID
52    pub chat_identifier: String,
53    /// The service the chat used, i.e. iMessage, SMS, IRC, etc.
54    pub service_name: Option<String>,
55    /// Optional custom name created created for the chat
56    pub display_name: Option<String>,
57}
58
59impl Table for Chat {
60    fn from_row(row: &Row) -> Result<Chat> {
61        Ok(Chat {
62            rowid: row.get("rowid")?,
63            chat_identifier: row.get("chat_identifier")?,
64            service_name: row.get("service_name")?,
65            display_name: row.get("display_name").unwrap_or(None),
66        })
67    }
68
69    fn get(db: &Connection) -> Result<CachedStatement, TableError> {
70        Ok(db.prepare_cached(&format!("SELECT * from {CHAT}"))?)
71    }
72
73    fn extract(chat: Result<Result<Self, Error>, Error>) -> Result<Self, TableError> {
74        match chat {
75            Ok(Ok(chat)) => Ok(chat),
76            Err(why) | Ok(Err(why)) => Err(TableError::QueryError(why)),
77        }
78    }
79}
80
81impl Cacheable for Chat {
82    type K = i32;
83    type V = Chat;
84    /// Generate a hashmap containing each chatroom's ID pointing to the chatroom's metadata.
85    ///
86    /// These chatroom ID's contain duplicates and must be deduped later once we have all of
87    /// the participants parsed out. On its own this data is not useful.
88    ///
89    /// # Example:
90    ///
91    /// ```
92    /// use imessage_database::util::dirs::default_db_path;
93    /// use imessage_database::tables::table::{Cacheable, get_connection};
94    /// use imessage_database::tables::chat::Chat;
95    ///
96    /// let db_path = default_db_path();
97    /// let conn = get_connection(&db_path).unwrap();
98    /// let chatrooms = Chat::cache(&conn);
99    /// ```
100    fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
101        let mut map = HashMap::new();
102
103        let mut statement = Chat::get(db)?;
104
105        let chats = statement.query_map([], |row| Ok(Chat::from_row(row)))?;
106
107        for chat in chats {
108            let result = Chat::extract(chat)?;
109            map.insert(result.rowid, result);
110        }
111        Ok(map)
112    }
113}
114
115impl Chat {
116    /// Generate a name for a chat, falling back to the default if a custom one is not set
117    #[must_use]
118    pub fn name(&self) -> &str {
119        match self.display_name() {
120            Some(name) => name,
121            None => &self.chat_identifier,
122        }
123    }
124
125    /// Get the current display name for the chat, if it exists.
126    #[must_use]
127    pub fn display_name(&self) -> Option<&str> {
128        match &self.display_name {
129            Some(name) => {
130                if !name.is_empty() {
131                    return Some(name.as_str());
132                }
133                None
134            }
135            None => None,
136        }
137    }
138
139    /// Get the service used by the chat, i.e. iMessage, SMS, IRC, etc.
140    #[must_use]
141    pub fn service(&self) -> Service {
142        Service::from(self.service_name.as_deref())
143    }
144
145    /// Get the [`Properties`] for the chat, if they exist
146    ///
147    /// Calling this hits the database, so it is expensive and should
148    /// only get invoked when needed.
149    #[must_use]
150    pub fn properties(&self, db: &Connection) -> Option<Properties> {
151        match Value::from_reader(self.get_blob(db, CHAT, PROPERTIES, self.rowid.into())?) {
152            Ok(plist) => Properties::from_plist(&plist).ok(),
153            Err(_) => None,
154        }
155    }
156}
157
158#[cfg(test)]
159mod test_properties {
160    use plist::Value;
161    use std::env::current_dir;
162    use std::fs::File;
163
164    use crate::tables::chat::Properties;
165
166    #[test]
167    fn test_can_parse_properties_simple() {
168        let plist_path = current_dir()
169            .unwrap()
170            .as_path()
171            .join("test_data/chat_properties/ChatProp1.plist");
172        let plist_data = File::open(plist_path).unwrap();
173        let plist = Value::from_reader(plist_data).unwrap();
174        println!("Parsed plist: {plist:#?}");
175
176        let actual = Properties::from_plist(&plist).unwrap();
177        let expected = Properties {
178            read_receipts_enabled: false,
179            last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
180            forced_sms: false,
181            group_photo_guid: None,
182        };
183        print!("Parsed properties: {expected:?}");
184        assert_eq!(actual, expected);
185    }
186
187    #[test]
188    fn test_can_parse_properties_enable_read_receipts() {
189        let plist_path = current_dir()
190            .unwrap()
191            .as_path()
192            .join("test_data/chat_properties/ChatProp2.plist");
193        let plist_data = File::open(plist_path).unwrap();
194        let plist = Value::from_reader(plist_data).unwrap();
195        println!("Parsed plist: {plist:#?}");
196
197        let actual = Properties::from_plist(&plist).unwrap();
198        let expected = Properties {
199            read_receipts_enabled: true,
200            last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
201            forced_sms: false,
202            group_photo_guid: None,
203        };
204        print!("Parsed properties: {expected:?}");
205        assert_eq!(actual, expected);
206    }
207
208    #[test]
209    fn test_can_parse_properties_third_with_summary() {
210        let plist_path = current_dir()
211            .unwrap()
212            .as_path()
213            .join("test_data/chat_properties/ChatProp3.plist");
214        let plist_data = File::open(plist_path).unwrap();
215        let plist = Value::from_reader(plist_data).unwrap();
216        println!("Parsed plist: {plist:#?}");
217
218        let actual = Properties::from_plist(&plist).unwrap();
219        let expected = Properties {
220            read_receipts_enabled: false,
221            last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
222            forced_sms: false,
223            group_photo_guid: None,
224        };
225        print!("Parsed properties: {expected:?}");
226        assert_eq!(actual, expected);
227    }
228
229    #[test]
230    fn test_can_parse_properties_forced_sms() {
231        let plist_path = current_dir()
232            .unwrap()
233            .as_path()
234            .join("test_data/chat_properties/ChatProp4.plist");
235        let plist_data = File::open(plist_path).unwrap();
236        let plist = Value::from_reader(plist_data).unwrap();
237        println!("Parsed plist: {plist:#?}");
238
239        let actual = Properties::from_plist(&plist).unwrap();
240        let expected = Properties {
241            read_receipts_enabled: false,
242            last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
243            forced_sms: true,
244            group_photo_guid: None,
245        };
246        print!("Parsed properties: {expected:?}");
247        assert_eq!(actual, expected);
248    }
249}