imessage_database/tables/
capabilities.rs1use std::path::PathBuf;
11
12use rusqlite::Connection;
13
14use crate::{
15 error::table::{TableConnectError, TableError},
16 tables::{
17 attachment::ATTACHMENT_COLUMNS,
18 diagnostic::{column_names, table_exists},
19 messages::columns::MESSAGE_COLUMNS,
20 table::{ATTACHMENT, MESSAGE, RECENTLY_DELETED},
21 },
22};
23
24const BODY_INPUT_COLUMNS: [&str; 3] = ["attributedbody", "message_summary_info", "payload_data"];
26
27#[derive(Clone, Debug)]
33pub struct Capabilities {
34 message_columns: Vec<&'static str>,
37 attachment_columns: Vec<&'static str>,
40 pub(crate) utf16_text: bool,
42 pub filter_actions: bool,
49 pub recoverable_messages: bool,
52 pub replies: bool,
55 pub associated_message_guids: bool,
58}
59
60impl Capabilities {
61 pub fn determine(db: &Connection) -> Result<Self, TableError> {
63 if !table_exists(db, MESSAGE)? {
64 return Err(TableError::CannotConnect(
65 TableConnectError::NotMessagesDatabase(PathBuf::from(
66 db.path().unwrap_or_default(),
67 )),
68 ));
69 }
70 let message_names = column_names(db, MESSAGE)?;
71 let attachment_names = column_names(db, ATTACHMENT)?;
72
73 Ok(Self {
74 message_columns: MESSAGE_COLUMNS
75 .into_iter()
76 .filter(|column| message_names.contains(*column))
77 .collect(),
78 attachment_columns: ATTACHMENT_COLUMNS
79 .into_iter()
80 .filter(|column| attachment_names.contains(*column))
81 .collect(),
82 utf16_text: db
83 .query_row("PRAGMA encoding", [], |row| row.get::<_, String>(0))?
84 .starts_with("UTF-16"),
85 filter_actions: message_names.contains("filter_action")
86 && message_names.contains("filter_sub_action"),
87 recoverable_messages: table_exists(db, RECENTLY_DELETED)?,
88 replies: message_names.contains("thread_originator_guid"),
89 associated_message_guids: message_names.contains("associated_message_guid"),
90 })
91 }
92
93 #[must_use]
100 pub fn without_derived_features(&self) -> Self {
101 Self {
102 recoverable_messages: false,
103 replies: false,
104 filter_actions: false,
105 ..self.clone()
106 }
107 }
108
109 #[must_use]
116 pub fn without_body_inputs(&self) -> Self {
117 Self {
118 message_columns: self
119 .message_columns
120 .iter()
121 .copied()
122 .filter(|column| !BODY_INPUT_COLUMNS.contains(column))
123 .collect(),
124 ..self.clone()
125 }
126 }
127
128 pub fn message_columns(&self) -> &[&'static str] {
130 &self.message_columns
131 }
132
133 pub fn attachment_columns(&self) -> &[&'static str] {
135 &self.attachment_columns
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use rusqlite::Connection;
142
143 use super::Capabilities;
144 use crate::{
145 error::table::{TableConnectError, TableError},
146 test_support::schema_db,
147 };
148
149 #[test]
150 fn rejects_database_without_message_table() {
151 let db = Connection::open_in_memory().unwrap();
152 db.execute_batch("CREATE TABLE Files (fileID TEXT)")
153 .unwrap();
154
155 assert!(matches!(
156 Capabilities::determine(&db),
157 Err(TableError::CannotConnect(
158 TableConnectError::NotMessagesDatabase(_)
159 ))
160 ));
161 }
162
163 #[test]
164 fn detects_present_features() {
165 let db = schema_db(true, true, true);
166 let capabilities = Capabilities::determine(&db).unwrap();
167
168 assert!(capabilities.filter_actions);
169 assert!(capabilities.recoverable_messages);
170 assert!(capabilities.replies);
171 assert!(capabilities.associated_message_guids);
172
173 assert_eq!(capabilities.message_columns().len(), 26);
175 }
176
177 #[test]
178 fn detects_absent_features() {
179 let db = schema_db(false, false, false);
180 let capabilities = Capabilities::determine(&db).unwrap();
181
182 assert!(!capabilities.filter_actions);
183 assert!(!capabilities.recoverable_messages);
184 assert!(!capabilities.replies);
185 assert!(
187 !capabilities
188 .message_columns()
189 .contains(&"thread_originator_guid")
190 );
191 }
192
193 #[test]
194 fn pre_tapback_schema_probes_no_associated_guids() {
195 let db = schema_db(false, false, false);
196 db.execute_batch("ALTER TABLE message DROP COLUMN associated_message_guid")
197 .unwrap();
198
199 assert!(
200 !Capabilities::determine(&db)
201 .unwrap()
202 .associated_message_guids
203 );
204 }
205
206 #[test]
207 fn attachment_columns_track_the_schema() {
208 let db = schema_db(false, false, false);
211 db.execute_batch(
212 "CREATE TABLE attachment (
213 ROWID INTEGER PRIMARY KEY,
214 guid TEXT,
215 filename TEXT,
216 uti TEXT,
217 mime_type TEXT,
218 transfer_name TEXT,
219 total_bytes INTEGER,
220 is_sticker INTEGER,
221 hide_attachment INTEGER
222 );",
223 )
224 .unwrap();
225
226 let capabilities = Capabilities::determine(&db).unwrap();
227
228 assert_eq!(capabilities.attachment_columns().len(), 9);
229 assert!(
230 !capabilities
231 .attachment_columns()
232 .contains(&"emoji_image_short_description")
233 );
234 }
235
236 #[test]
237 fn without_body_inputs_drops_only_the_parser_columns() {
238 let db = schema_db(true, true, true);
239 db.execute_batch(
240 "ALTER TABLE message ADD COLUMN attributedBody BLOB;
241 ALTER TABLE message ADD COLUMN message_summary_info BLOB;
242 ALTER TABLE message ADD COLUMN payload_data BLOB;",
243 )
244 .unwrap();
245 let capabilities = Capabilities::determine(&db).unwrap();
246 assert_eq!(capabilities.message_columns().len(), 29);
247
248 let lean = capabilities.without_body_inputs();
249 let expected: Vec<&str> = capabilities
250 .message_columns()
251 .iter()
252 .copied()
253 .filter(|column| {
254 !matches!(
255 *column,
256 "attributedbody" | "message_summary_info" | "payload_data"
257 )
258 })
259 .collect();
260 assert_eq!(lean.message_columns(), expected.as_slice());
261 assert_eq!(lean.message_columns().len(), 26);
262 assert!(lean.filter_actions);
263 assert!(lean.recoverable_messages);
264 assert!(lean.replies);
265 }
266}