imessage_database/tables/messages/message.rs
1/*!
2 Message table rows, query helpers, and body parsing.
3
4 # Iterating Over Message Data
5
6 Compose the message query from probed [`Capabilities`]. For [`Message`],
7 use [`Message::stream_rows()`] to prepare the query and [`Message::rows()`]
8 to iterate over it; there is no [`Table`](crate::tables::table::Table)
9 implementation for this type.
10
11 ## Example
12 ```no_run
13 use imessage_database::{
14 tables::{
15 capabilities::Capabilities,
16 messages::Message,
17 table::get_connection,
18 },
19 util::{dirs::default_db_path, query_context::QueryContext},
20 };
21
22 // Get the default database path and connect to it
23 let db_path = default_db_path().unwrap();
24 let conn = get_connection(&db_path).unwrap();
25
26 // Probe the schema once and use the result to compose every message query
27 let capabilities = Capabilities::determine(&conn).unwrap();
28 let query_context = QueryContext::default();
29
30 let mut statement = Message::stream_rows(&conn, &capabilities, &query_context).unwrap();
31 for message_result in Message::rows(&mut statement, []).unwrap() {
32 match message_result {
33 Ok(message) => println!("Message: {:#?}", message),
34 Err(e) => eprintln!("Error: {:?}", e),
35 }
36 }
37 ```
38
39 # Iterating Over Filtered Message Data
40
41 Use a [`QueryContext`] to specify filters and constraints for the message query.
42
43 ## Example
44 ```no_run
45 use chrono::Utc;
46 use imessage_database::{
47 tables::{
48 capabilities::Capabilities,
49 messages::Message,
50 table::get_connection,
51 },
52 util::{dirs::default_db_path, query_context::QueryContext},
53 };
54
55 // Get the default database path and connect to it
56 let db_path = default_db_path().unwrap();
57 let conn = get_connection(&db_path).unwrap();
58 let capabilities = Capabilities::determine(&conn).unwrap();
59 let mut query_context = QueryContext::default();
60
61 // Customize the query context as needed
62 // For example, filter messages by date using midnight in `Utc`:
63 query_context.set_start("2023-01-01", &Utc).unwrap();
64 query_context.set_end("2023-12-31", &Utc).unwrap();
65
66 // Prepare the statement for streaming rows
67 let mut statement = Message::stream_rows(&conn, &capabilities, &query_context).unwrap();
68
69 // Iterate over the streamed rows and handle each message result
70 for message_result in Message::rows(&mut statement, []).unwrap() {
71 match message_result {
72 Ok(message) => println!("Message: {:#?}", message),
73 Err(e) => eprintln!("Error: {:?}", e),
74 }
75 }
76 ```
77
78 # Making Custom Message Queries
79
80 [`Message`] includes a few fields that are derived by the default query and
81 are not direct `message` table columns:
82
83 - [`Message::chat_id`]
84 - [`Message::num_attachments`]
85 - [`Message::deleted_from`]
86 - [`Message::num_replies`]
87
88 [`Message::rows`] and [`Message::row`] resolve result columns by name once,
89 then decode by ordinal. Column order is immaterial; column names are not. Six
90 columns are mandatory: `rowid`, `guid`, `date`, `is_from_me`,
91 `num_attachments`, and `num_replies`. A query that omits any of them fails to
92 deserialize. Every other column [`Message`] reads may be omitted and takes its
93 default. Thus,
94 [`Message::filter_action`] and [`Message::filter_sub_action`] read as `None`
95 against schemas without those columns.
96
97 Body parsing uses optional `attributedBody`, `message_summary_info`, and
98 `has_payload_data` result columns. The payload flag is `payload_data IS NOT
99 NULL`; custom `m.*` projections can supply raw `payload_data` instead. Missing
100 parser inputs are treated as absent, so a projection containing only `text`
101 still produces plain-text body components. Text recovery failures preserve
102 the row's metadata and any recovered body components or edit metadata.
103
104 Built-in queries preserve the storage bytes of text-valued parser inputs in
105 UTF-16 databases. Custom queries must cast such inputs to `BLOB` to prevent
106 SQLite from transcoding them to UTF-8 before parsing.
107
108 ## Sample Queries
109
110 Custom queries must include those derived columns:
111
112 ```sql
113 SELECT
114 *,
115 c.chat_id,
116 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
117 d.chat_id as deleted_from,
118 (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
119 FROM
120 message as m
121 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
122 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
123 ORDER BY
124 m.date;
125 ```
126
127 If a source database does not include recoverable-message or reply columns,
128 synthesize the missing values:
129
130 ```sql
131 SELECT
132 *,
133 c.chat_id,
134 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
135 NULL as deleted_from,
136 0 as num_replies
137 FROM
138 message as m
139 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
140 ORDER BY
141 m.date;
142 ```
143
144 ## Custom Query Example
145
146 This returns an iterator over messages that have an associated emoji:
147
148
149 ```no_run
150 use imessage_database::{
151 tables::{messages::Message, table::get_connection},
152 util::dirs::default_db_path
153 };
154
155 let db_path = default_db_path().unwrap();
156 let db = get_connection(&db_path).unwrap();
157
158 let mut statement = db.prepare_cached("
159 SELECT
160 *,
161 c.chat_id,
162 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
163 d.chat_id as deleted_from,
164 (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
165 FROM
166 message as m
167 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
168 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
169 WHERE m.associated_message_emoji IS NOT NULL
170 ORDER BY
171 m.date;
172 ").unwrap();
173
174 for message in Message::rows(&mut statement, []).unwrap() {
175 println!("{:#?}", message);
176 }
177 ```
178*/
179
180use std::{
181 collections::{HashMap, HashSet},
182 fmt::Write,
183 io::{Cursor, Read},
184};
185
186use chrono::{DateTime, TimeZone, Utc};
187use crabstep::TypedStreamDeserializer;
188use plist::Value;
189use rusqlite::{CachedStatement, Connection, Params, Result, Statement};
190
191use crate::{
192 error::{message::MessageError, table::TableError},
193 message_types::{
194 edited::{EditStatus, EditedMessage},
195 expressives::{BubbleEffect, Expressive, ScreenEffect},
196 polls::Poll,
197 text_effects::text_effect::TextEffect,
198 translation::Translation,
199 variants::{Announcement, BalloonProvider, CustomBalloon, Tapback, TapbackAction, Variant},
200 },
201 tables::{
202 capabilities::Capabilities,
203 diagnostic::{MessageDiagnostic, count_query, table_exists},
204 messages::{
205 body::{parse_body_legacy, parse_body_typedstream},
206 columns::MessageColumns,
207 models::{BubbleComponent, FilterAction, GroupAction, Service, SharedLocation},
208 query_parts::{from_clause, message_query, prepare_message_query},
209 },
210 table::{
211 CHAT_MESSAGE_JOIN, MESSAGE, MESSAGE_PAYLOAD, MESSAGE_SUMMARY_INFO, RECENTLY_DELETED,
212 flatten_row, open_blob,
213 },
214 },
215 util::{
216 bundle_id::parse_balloon_bundle_id,
217 dates::{readable_diff, to_utc},
218 query_context::QueryContext,
219 streamtyped,
220 },
221};
222
223/// Row from the `message` table, with body and edit metadata parsed during row decoding.
224#[derive(Debug)]
225pub struct Message {
226 /// Message row ID.
227 pub rowid: i32,
228 /// Message GUID.
229 pub guid: String,
230 /// Plain body text, decoded from `attributedBody` when available.
231 pub text: Option<String>,
232 /// Raw service name.
233 pub service: Option<String>,
234 /// Sender handle row ID.
235 pub handle_id: Option<i32>,
236 /// Address that received the message.
237 pub destination_caller_id: Option<String>,
238 /// Subject field.
239 pub subject: Option<String>,
240 /// Raw timestamp for when the message was written to the database.
241 pub date: i64,
242 /// Raw timestamp for when the message was read.
243 pub date_read: i64,
244 /// Raw timestamp for when the message was delivered.
245 pub date_delivered: i64,
246 /// `true` when the database owner sent the message.
247 pub is_from_me: bool,
248 /// `true` when the message was read by the recipient.
249 pub is_read: bool,
250 /// Message item type used by [`variant`](Self::variant).
251 pub item_type: i32,
252 /// Additional handle used by shared-location and group-action messages.
253 pub other_handle: Option<i32>,
254 /// Shared-location active/inactive flag.
255 pub share_status: bool,
256 /// Shared-location direction flag.
257 pub share_direction: Option<bool>,
258 /// Group title carried by group-name-change messages.
259 pub group_title: Option<String>,
260 /// Group action code.
261 pub group_action_type: i32,
262 /// GUID of the message this row references.
263 pub associated_message_guid: Option<String>,
264 /// Type code for the associated message, used by [`variant`](Self::variant).
265 pub associated_message_type: Option<i32>,
266 /// The [bundle ID](https://developer.apple.com/help/app-store-connect/reference/app-bundle-information) of the app that generated the [`AppMessage`](crate::message_types::app::AppMessage)
267 pub balloon_bundle_id: Option<String>,
268 /// Expressive-send identifier used by [`get_expressive`](Self::get_expressive).
269 pub expressive_send_style_id: Option<String>,
270 /// Indicates the first message in a thread of replies in [`get_replies()`](crate::tables::messages::Message::get_replies)
271 pub thread_originator_guid: Option<String>,
272 /// Body part index targeted by a reply.
273 pub thread_originator_part: Option<String>,
274 /// Raw timestamp for the most recent edit.
275 pub date_edited: i64,
276 /// Emoji associated with a custom emoji tapback.
277 pub associated_message_emoji: Option<String>,
278 /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat this message belongs to.
279 ///
280 /// `None` for a recovered deleted message; most callers should prefer
281 /// [`chat_rowid()`](Self::chat_rowid).
282 pub chat_id: Option<i32>,
283 /// Number of attached files included in the message.
284 pub num_attachments: i32,
285 /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat the message was deleted from.
286 ///
287 /// Most callers should prefer [`chat_rowid()`](Self::chat_rowid).
288 pub deleted_from: Option<i32>,
289 /// Number of replies to the message.
290 pub num_replies: i32,
291 /// Raw message filter category code, read by [`filter_action`](Self::filter_action).
292 pub filter_action: Option<i32>,
293 /// Raw message filter subcategory code. This field has no parsed representation.
294 pub filter_sub_action: Option<i32>,
295 /// The components of the message body, parsed by a [`TypedStreamDeserializer`] or [`streamtyped::parse()`]
296 pub components: Vec<BubbleComponent>,
297 /// Parsed edit/unsent metadata from `message_summary_info`.
298 pub edited_parts: Option<EditedMessage>,
299}
300
301// MARK: Rows
302impl Message {
303 /// Resolve the column layout after the first step, then deserialize every
304 /// row by ordinal. Pass either a [`rusqlite::Statement`] or a
305 /// [`rusqlite::CachedStatement`] (via deref coercion).
306 ///
307 /// If a required column is absent, return [`TableError::QueryError`] for
308 /// every row. Failed layout resolutions are not cached: retry resolution
309 /// after each step.
310 ///
311 /// Resolve through the first [`Row`](rusqlite::Row) to use metadata after
312 /// `sqlite3_step`: if the schema changed after preparation, the statement
313 /// may be recompiled during that step.
314 pub fn rows<'stmt, P: Params>(
315 stmt: &'stmt mut Statement<'_>,
316 params: P,
317 ) -> Result<impl Iterator<Item = Result<Self, TableError>> + 'stmt, TableError>
318 where
319 Self: 'stmt,
320 {
321 let mut layout = None;
322 let mapped = stmt.query_map(params, move |row| {
323 let columns = match layout.as_ref() {
324 Some(columns) => columns,
325 None => layout.insert(MessageColumns::resolve(row.as_ref())?),
326 };
327 Ok(Self::from_row_mapped(row, columns))
328 })?;
329 Ok(mapped.map(flatten_row))
330 }
331
332 /// Resolve the stepped row's column layout, then deserialize by ordinal.
333 /// Return [`TableError::QueryError`] if no row is found, a required column
334 /// is absent, or deserialization fails.
335 pub fn row<P: Params>(stmt: &mut Statement<'_>, params: P) -> Result<Self, TableError> {
336 flatten_row(stmt.query_row(params, |row| {
337 let columns = MessageColumns::resolve(row.as_ref())?;
338 Ok(Self::from_row_mapped(row, &columns))
339 }))
340 }
341}
342
343// MARK: Diagnostic
344impl Message {
345 /// Compute diagnostic data for the `message` table.
346 ///
347 /// # Example
348 ///
349 /// ```no_run
350 /// use imessage_database::util::dirs::default_db_path;
351 /// use imessage_database::tables::table::get_connection;
352 /// use imessage_database::tables::messages::Message;
353 ///
354 /// let db_path = default_db_path().unwrap();
355 /// let conn = get_connection(&db_path).unwrap();
356 /// Message::run_diagnostic(&conn);
357 /// ```
358 pub fn run_diagnostic(db: &Connection) -> Result<MessageDiagnostic, TableError> {
359 let messages_without_chat = count_query(
360 db,
361 &format!(
362 "
363 SELECT
364 COUNT(m.rowid)
365 FROM
366 {MESSAGE} as m
367 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.rowid = c.message_id
368 WHERE
369 c.chat_id is NULL
370 ORDER BY
371 m.date
372 "
373 ),
374 )?;
375
376 let messages_in_multiple_chats = count_query(
377 db,
378 &format!(
379 "
380 SELECT
381 COUNT(*)
382 FROM (
383 SELECT DISTINCT
384 message_id
385 , COUNT(chat_id) AS c
386 FROM {CHAT_MESSAGE_JOIN}
387 GROUP BY
388 message_id
389 HAVING c > 1);
390 "
391 ),
392 )?;
393
394 let total_messages = count_query(
395 db,
396 &format!(
397 "
398 SELECT
399 COUNT(rowid)
400 FROM
401 {MESSAGE}
402 "
403 ),
404 )?;
405
406 // Recently deleted messages are stored in a separate table when present.
407 let recoverable_messages = if table_exists(db, RECENTLY_DELETED)? {
408 Some(count_query(
409 db,
410 &format!("SELECT COUNT(*) FROM {RECENTLY_DELETED}"),
411 )?)
412 } else {
413 None
414 };
415
416 // The date range is nullable when the message table is empty.
417 let mut date_range = db.prepare(&format!("SELECT MIN(date), MAX(date) FROM {MESSAGE}"))?;
418 let (first_message_date, last_message_date): (Option<i64>, Option<i64>) = date_range
419 .query_row([], |r| Ok((r.get(0).ok(), r.get(1).ok())))
420 .unwrap_or((None, None));
421
422 Ok(MessageDiagnostic {
423 total_messages,
424 messages_without_chat,
425 messages_in_multiple_chats,
426 recoverable_messages,
427 first_message_date,
428 last_message_date,
429 })
430 }
431}
432
433// MARK: Cache
434impl Message {
435 /// Cache tapback messages by target message GUID and body component index.
436 ///
437 /// Builds a map like:
438 ///
439 /// ```json
440 /// {
441 /// "message_guid": {
442 /// 0: [Message, Message],
443 /// 1: [Message]
444 /// }
445 /// }
446 /// ```
447 ///
448 /// The `0` and `1` keys are component indexes in the target message body.
449 /// Return an empty map without querying if `associated_message_guid` is
450 /// absent from the schema.
451 pub fn tapbacks_by_target(
452 db: &Connection,
453 capabilities: &Capabilities,
454 ) -> Result<HashMap<String, HashMap<usize, Vec<Message>>>, TableError> {
455 let mut map: HashMap<String, HashMap<usize, Vec<Message>>> = HashMap::new();
456
457 if !capabilities.associated_message_guids {
458 return Ok(map);
459 }
460
461 // The cache only maps each tapback to its target GUID and component
462 // index, and tapback rendering never reads a body, so this full scan
463 // skips the derived features' correlated subqueries and join and the
464 // body inputs' blob reads and typedstream decode.
465 let cache_capabilities = capabilities
466 .without_derived_features()
467 .without_body_inputs();
468 let mut statement = db.prepare_cached(&message_query(
469 &cache_capabilities,
470 Some("WHERE m.associated_message_guid IS NOT NULL"),
471 ))?;
472
473 for message in Self::rows(&mut statement, [])? {
474 let message = message?;
475 if message.is_tapback()
476 && let Some((idx, tapback_target_guid)) = message.clean_associated_guid()
477 {
478 map.entry(tapback_target_guid.to_string())
479 .or_default()
480 .entry(idx)
481 .or_default()
482 .push(message);
483 }
484 }
485
486 Ok(map)
487 }
488}
489
490// MARK: Impl
491impl Message {
492 // MARK: Text Gen
493 /// Populate body fields from supplied bytes without reading the database.
494 ///
495 /// `None` represents an absent or unreadable input. `has_payload` is the
496 /// non-nullness of `payload_data`, including non-blob values.
497 /// Edit-summary bytes are parsed only when [`Self::is_edited`] is true.
498 ///
499 /// Parsing is best effort: text recovery failures preserve parsed body
500 /// components and edit metadata.
501 pub(super) fn parse_body(
502 &mut self,
503 attributed_body: Option<&[u8]>,
504 message_summary_info: Option<&[u8]>,
505 has_payload: bool,
506 ) {
507 let edited_parts = self
508 .is_edited()
509 .then_some(message_summary_info)
510 .flatten()
511 .and_then(|bytes| Value::from_reader(Cursor::new(bytes)).ok())
512 .as_ref()
513 .and_then(|payload| EditedMessage::from_map(payload).ok());
514
515 // Initialize variables for the text, components, and balloon bundle ID that will be parsed from the body
516 let mut text = None;
517 let mut components = vec![];
518 let mut balloon_bundle_id = None;
519
520 if let Some(body) = attributed_body {
521 // Attempt to deserialize the typedstream data
522 let mut typedstream = TypedStreamDeserializer::new(body);
523 match parse_body_typedstream(typedstream.iter_root().ok(), edited_parts.as_ref()) {
524 Some(parsed) => {
525 text = parsed.text;
526
527 // Single-link messages can render as URL previews even
528 // when `balloon_bundle_id` is missing.
529 let is_single_url = match &parsed.components[..] {
530 [BubbleComponent::Run(ranges)] => match &ranges[..] {
531 [range] if range.attachment.is_none() => {
532 matches!(&range.effects[..], [TextEffect::Link(_)])
533 }
534 _ => false,
535 },
536 _ => false,
537 };
538
539 // App payloads render as a single app component.
540 if self.balloon_bundle_id.is_some() {
541 components = vec![BubbleComponent::App];
542 } else if is_single_url && has_payload {
543 // URL previews may omit `balloon_bundle_id` while still carrying
544 // preview payload data.
545 balloon_bundle_id =
546 Some("com.apple.messages.URLBalloonProvider".to_string());
547 components = vec![BubbleComponent::App];
548 } else {
549 components = parsed.components;
550 }
551 }
552 None => {
553 // Typedstream failed entirely; try self.text before legacy parser
554 text = self.text.clone();
555 }
556 }
557
558 // A failed legacy parse must not discard valid components or edit metadata.
559 if text.is_none() {
560 text = streamtyped::parse(body.to_vec()).ok();
561 }
562 }
563
564 // Older message rows may already have the plain text field populated.
565 let text = text.or_else(|| self.text.clone());
566
567 // The balloon bundle ID can be set in the single URL case, otherwise it should fall back to the existing balloon bundle ID on the message
568 let balloon_bundle_id = balloon_bundle_id.or_else(|| self.balloon_bundle_id.clone());
569
570 // If typedstream did not produce components, derive simple text ranges.
571 if components.is_empty() && text.is_some() {
572 components = parse_body_legacy(&text);
573 }
574
575 self.text = text;
576 self.components = components;
577 self.edited_parts = edited_parts;
578 self.balloon_bundle_id = balloon_bundle_id;
579 }
580
581 // MARK: Dates
582 /// Decode [`date`](Self::date) to an instant.
583 ///
584 /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
585 /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
586 pub fn date(&self) -> Result<DateTime<Utc>, MessageError> {
587 to_utc(self.date)
588 }
589
590 /// Decode [`date_delivered`](Self::date_delivered) to an instant.
591 ///
592 /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
593 /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
594 pub fn date_delivered(&self) -> Result<DateTime<Utc>, MessageError> {
595 to_utc(self.date_delivered)
596 }
597
598 /// Decode [`date_read`](Self::date_read) to an instant.
599 ///
600 /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
601 /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
602 pub fn date_read(&self) -> Result<DateTime<Utc>, MessageError> {
603 to_utc(self.date_read)
604 }
605
606 /// Decode [`date_edited`](Self::date_edited) to an instant.
607 ///
608 /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
609 /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
610 pub fn date_edited(&self) -> Result<DateTime<Utc>, MessageError> {
611 to_utc(self.date_edited)
612 }
613
614 /// Calculate the elapsed time until the message was read or delivered.
615 ///
616 /// This can happen in two ways:
617 ///
618 /// - You received a message, then waited to read it
619 /// - You sent a message, and the recipient waited to read it
620 ///
621 /// In the former case, this computes the difference from the date received (`date`) to the date read (`date_read`).
622 /// In the latter case, this computes the difference from the date sent (`date`) to the date delivered (`date_delivered`).
623 ///
624 /// Not all messages get tagged with the read properties.
625 /// If more than one message has been sent in a thread before getting read,
626 /// only the most recent message will get the tag.
627 ///
628 /// Use the calendar in `zone` to count whole years; see
629 /// [`readable_diff`](crate::util::dates::readable_diff).
630 #[must_use]
631 pub fn time_until_read<Tz: TimeZone>(&self, zone: &Tz) -> Option<String> {
632 // Message we received
633 if !self.is_from_me && self.date_read != 0 && self.date != 0 {
634 return readable_diff(
635 &self.date().ok()?.with_timezone(zone),
636 &self.date_read().ok()?.with_timezone(zone),
637 );
638 }
639 // Message we sent
640 else if self.is_from_me && self.date_delivered != 0 && self.date != 0 {
641 return readable_diff(
642 &self.date().ok()?.with_timezone(zone),
643 &self.date_delivered().ok()?.with_timezone(zone),
644 );
645 }
646 None
647 }
648
649 // MARK: Bools
650 /// `true` when the message is a thread reply.
651 #[must_use]
652 pub fn is_reply(&self) -> bool {
653 self.thread_originator_guid.is_some()
654 }
655
656 /// `true` when the message is an [`Announcement`].
657 #[must_use]
658 pub fn is_announcement(&self) -> bool {
659 self.get_announcement().is_some()
660 }
661
662 /// `true` when the message is a [`Tapback`] to another message.
663 #[must_use]
664 pub fn is_tapback(&self) -> bool {
665 matches!(self.variant(), Variant::Tapback(..))
666 }
667
668 /// `true` when the message has an [`Expressive`] send effect.
669 #[must_use]
670 pub fn is_expressive(&self) -> bool {
671 self.expressive_send_style_id.is_some()
672 }
673
674 /// `true` when the message has a [URL preview](crate::message_types::url).
675 #[must_use]
676 pub fn is_url(&self) -> bool {
677 matches!(self.variant(), Variant::App(CustomBalloon::URL))
678 }
679
680 /// `true` when the message is a [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage).
681 #[must_use]
682 pub fn is_handwriting(&self) -> bool {
683 matches!(self.variant(), Variant::App(CustomBalloon::Handwriting))
684 }
685
686 /// `true` when the message is a [`Digital Touch`](crate::message_types::digital_touch) message.
687 #[must_use]
688 pub fn is_digital_touch(&self) -> bool {
689 matches!(self.variant(), Variant::App(CustomBalloon::DigitalTouch))
690 }
691
692 /// `true` when the message is a [`Poll`].
693 #[must_use]
694 pub fn is_poll(&self) -> bool {
695 matches!(self.variant(), Variant::App(CustomBalloon::Polls))
696 }
697
698 /// `true` when the message is a [`PollVote`](crate::message_types::polls::PollVote).
699 #[must_use]
700 pub fn is_poll_vote(&self) -> bool {
701 self.associated_message_type == Some(4000)
702 }
703
704 /// `true` when the message adds or updates poll options.
705 #[must_use]
706 pub fn is_poll_update(&self) -> bool {
707 matches!(self.variant(), Variant::PollUpdate)
708 }
709
710 /// `true` when the message was [`edited`](crate::message_types::edited).
711 #[must_use]
712 pub fn is_edited(&self) -> bool {
713 self.date_edited != 0
714 }
715
716 /// `true` when the specified message component was [edited](crate::message_types::edited::EditStatus::Edited).
717 #[must_use]
718 pub fn is_part_edited(&self, index: usize) -> bool {
719 if let Some(edited_parts) = &self.edited_parts
720 && let Some(part) = edited_parts.part(index)
721 {
722 return matches!(part.status, EditStatus::Edited);
723 }
724 false
725 }
726
727 /// `true` when all message components were [unsent](crate::message_types::edited::EditStatus::Unsent).
728 #[must_use]
729 pub fn is_fully_unsent(&self) -> bool {
730 self.edited_parts.as_ref().is_some_and(|ep| {
731 ep.parts
732 .iter()
733 .all(|part| matches!(part.status, EditStatus::Unsent))
734 })
735 }
736
737 /// `true` when the message contains [`Attachment`](crate::tables::attachment::Attachment)s.
738 ///
739 /// Attachments can be queried with [`Attachment::from_message()`](crate::tables::attachment::Attachment::from_message).
740 #[must_use]
741 pub fn has_attachments(&self) -> bool {
742 self.num_attachments > 0
743 }
744
745 /// `true` when the message begins a thread.
746 #[must_use]
747 pub fn has_replies(&self) -> bool {
748 self.num_replies > 0
749 }
750
751 /// `true` when the message indicates a sent audio message was kept.
752 #[must_use]
753 pub fn is_kept_audio_message(&self) -> bool {
754 self.item_type == 5
755 }
756
757 /// `true` when the message is a [SharePlay/FaceTime](crate::message_types::variants::Variant::SharePlay) message.
758 #[must_use]
759 pub fn is_shareplay(&self) -> bool {
760 self.item_type == 6
761 }
762
763 /// `true` when the message was sent by the database owner.
764 #[must_use]
765 pub fn is_from_me(&self) -> bool {
766 // Share direction and other handle are only populated for shared location messages,
767 // so this check is only necessary for those
768 if self.item_type == 4
769 && let (Some(other_handle), Some(share_direction)) =
770 (self.other_handle, self.share_direction)
771 {
772 self.is_from_me || other_handle != 0 && !share_direction
773 } else {
774 self.is_from_me
775 }
776 }
777
778 /// Returns the [`SharedLocation`] when the message is a legacy
779 /// shared-location event.
780 #[must_use]
781 pub fn shared_location_kind(&self) -> Option<SharedLocation> {
782 if self.item_type == 4 && self.group_action_type == 0 {
783 Some(if self.share_status {
784 SharedLocation::Stopped
785 } else {
786 SharedLocation::Started
787 })
788 } else {
789 None
790 }
791 }
792
793 /// `true` when the message is present in the recoverable deleted-message table.
794 ///
795 /// Messages removed by deleting an entire conversation or by deleting a single message
796 /// from a conversation are moved to a separate collection for up to 30 days. Messages
797 /// present in this collection are restored to the conversations they belong to. Apple
798 /// details this process [here](https://support.apple.com/en-us/HT202549#delete).
799 ///
800 /// Messages that have expired from this restoration process are permanently deleted and
801 /// cannot be recovered.
802 ///
803 /// Note: This is not the same as an [`Unsent`](crate::message_types::edited::EditStatus::Unsent) message.
804 #[must_use]
805 pub fn is_deleted(&self) -> bool {
806 self.deleted_from.is_some()
807 }
808
809 /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat this message belongs to.
810 ///
811 /// A recovered deleted message has no `chat_message_join` row, so its
812 /// [`chat_id`](Self::chat_id) is `None` and [`deleted_from`](Self::deleted_from)
813 /// is the only reference to its chat. Resolve a message's chat through this
814 /// method so its transcript and attachments land in the same conversation.
815 #[must_use]
816 pub fn chat_rowid(&self) -> Option<i32> {
817 self.chat_id.or(self.deleted_from)
818 }
819
820 /// `true` when the message summary includes translation metadata.
821 pub fn has_translation(&self, db: &Connection) -> bool {
822 // `7472616E736C6174696F6E4C616E6775616765` -> "translationLanguage"
823 // `7472616E736C6174656454657874` -> "translatedText"
824 let query = format!(
825 "SELECT ROWID FROM {MESSAGE}
826 WHERE message_summary_info IS NOT NULL
827 AND length(message_summary_info) > 61
828 AND instr(message_summary_info, X'7472616E736C6174696F6E4C616E6775616765') > 0
829 AND instr(message_summary_info, X'7472616E736C6174656454657874') > 0
830 AND ROWID = ?"
831 );
832 if let Ok(mut statement) = db.prepare_cached(&query) {
833 let result: Result<i32, _> = statement.query_row([self.rowid], |row| row.get(0));
834 result.is_ok()
835 } else {
836 false
837 }
838 }
839
840 /// Parse translation metadata for the message.
841 pub fn get_translation(&self, db: &Connection) -> Result<Option<Translation>, MessageError> {
842 if let Some(payload) = self.message_summary_info(db) {
843 return Ok(Some(Translation::from_payload(&payload)?));
844 }
845 Ok(None)
846 }
847
848 /// Cache message GUIDs whose summaries include translation metadata.
849 pub fn cache_translations(db: &Connection) -> Result<HashSet<String>, TableError> {
850 // `7472616E736C6174696F6E4C616E6775616765` -> "translationLanguage"
851 // `7472616E736C6174656454657874` -> "translatedText"
852 let query = format!(
853 "SELECT guid FROM {MESSAGE}
854 WHERE message_summary_info IS NOT NULL
855 AND length(message_summary_info) > 61
856 AND instr(message_summary_info, X'7472616E736C6174696F6E4C616E6775616765') > 0
857 AND instr(message_summary_info, X'7472616E736C6174656454657874') > 0"
858 );
859
860 let mut statement = db.prepare(&query)?;
861 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
862
863 let mut guids = HashSet::new();
864 for guid_result in rows {
865 guids.insert(guid_result?);
866 }
867
868 Ok(guids)
869 }
870
871 /// Parse the group action encoded by the message.
872 #[must_use]
873 pub fn group_action(&'_ self) -> Option<GroupAction<'_>> {
874 GroupAction::from_message(self)
875 }
876
877 /// Parse the body component index targeted by a reply.
878 fn get_reply_index(&self) -> usize {
879 if let Some(parts) = &self.thread_originator_part {
880 return match parts.split(':').next() {
881 Some(part) => str::parse::<usize>(part).unwrap_or(0),
882 None => 0,
883 };
884 }
885 0
886 }
887
888 // MARK: SQL
889 /// Build the SQL `WHERE` clause described by a [`QueryContext`].
890 ///
891 /// If `include_recoverable` is `true`, the filter includes messages from the recently deleted messages
892 /// table that match the chat IDs. This allows recovery of deleted messages that are still
893 /// present in the database but no longer visible in the Messages app.
894 pub(crate) fn generate_filter_statement(
895 context: &QueryContext,
896 include_recoverable: bool,
897 ) -> String {
898 let mut filters = String::with_capacity(128);
899
900 // Start date filter
901 if let Some(start) = context.start {
902 let _ = write!(filters, " m.date >= {start}");
903 }
904
905 // End date filter
906 if let Some(end) = context.end {
907 if !filters.is_empty() {
908 filters.push_str(" AND ");
909 }
910 let _ = write!(filters, " m.date <= {end}");
911 }
912
913 // Chat ID filter, optionally including recoverable messages
914 if let Some(chat_ids) = &context.selected_chat_ids {
915 if !filters.is_empty() {
916 filters.push_str(" AND ");
917 }
918
919 // Allocate the filter string for interpolation
920 let ids = chat_ids
921 .iter()
922 .map(std::string::ToString::to_string)
923 .collect::<Vec<String>>()
924 .join(", ");
925
926 if include_recoverable {
927 let _ = write!(filters, " (c.chat_id IN ({ids}) OR d.chat_id IN ({ids}))");
928 } else {
929 let _ = write!(filters, " c.chat_id IN ({ids})");
930 }
931 }
932
933 if !filters.is_empty() {
934 return format!("WHERE {filters}");
935 }
936 filters
937 }
938
939 /// Count messages matching the provided query context.
940 ///
941 /// # Example
942 ///
943 /// ```no_run
944 /// use imessage_database::tables::{
945 /// capabilities::Capabilities,
946 /// messages::Message,
947 /// table::get_connection,
948 /// };
949 /// use imessage_database::util::dirs::default_db_path;
950 /// use imessage_database::util::query_context::QueryContext;
951 ///
952 /// let db_path = default_db_path().unwrap();
953 /// let conn = get_connection(&db_path).unwrap();
954 /// let capabilities = Capabilities::determine(&conn).unwrap();
955 /// let context = QueryContext::default();
956 /// Message::get_count(&conn, &capabilities, &context);
957 /// ```
958 pub fn get_count(
959 db: &Connection,
960 capabilities: &Capabilities,
961 context: &QueryContext,
962 ) -> Result<i64, TableError> {
963 // The unfiltered count skips the chat join: `chat_message_join` can
964 // associate one message with several chats, and every extra
965 // association would inflate `COUNT(*)`.
966 let mut statement = if context.has_filters() {
967 let filters =
968 Self::generate_filter_statement(context, capabilities.recoverable_messages);
969 db.prepare_cached(&format!(
970 "SELECT COUNT(*){}\n{filters}",
971 from_clause(capabilities)
972 ))?
973 } else {
974 db.prepare_cached(&format!("SELECT COUNT(*) FROM {MESSAGE}"))?
975 };
976 // Execute query, defaulting to zero if it fails
977 let count: i64 = statement.query_row([], |r| r.get(0)).unwrap_or(0);
978
979 Ok(count)
980 }
981
982 /// Stream messages from the database with optional filters.
983 ///
984 /// # Example
985 ///
986 /// ```no_run
987 /// use imessage_database::tables::{
988 /// capabilities::Capabilities,
989 /// messages::Message,
990 /// table::get_connection,
991 /// };
992 /// use imessage_database::util::dirs::default_db_path;
993 /// use imessage_database::util::query_context::QueryContext;
994 ///
995 /// let db_path = default_db_path().unwrap();
996 /// let conn = get_connection(&db_path).unwrap();
997 /// let capabilities = Capabilities::determine(&conn).unwrap();
998 /// let context = QueryContext::default();
999 ///
1000 /// let mut statement = Message::stream_rows(&conn, &capabilities, &context).unwrap();
1001 ///
1002 /// for message in Message::rows(&mut statement, []).unwrap() {
1003 /// println!("{:#?}", message);
1004 /// }
1005 /// ```
1006 pub fn stream_rows<'a>(
1007 db: &'a Connection,
1008 capabilities: &Capabilities,
1009 context: &'a QueryContext,
1010 ) -> Result<CachedStatement<'a>, TableError> {
1011 let filters = context
1012 .has_filters()
1013 .then(|| Self::generate_filter_statement(context, capabilities.recoverable_messages));
1014 prepare_message_query(db, capabilities, filters.as_deref())
1015 }
1016
1017 /// Parse the target body component index and GUID from `associated_message_guid`.
1018 ///
1019 /// Returns a tuple of (component index, message GUID) if present.
1020 #[must_use]
1021 pub fn clean_associated_guid(&self) -> Option<(usize, &str)> {
1022 if let Some(guid) = &self.associated_message_guid {
1023 if guid.starts_with("p:") {
1024 let mut split = guid.split('/');
1025 let index_str = split.next()?;
1026 let message_id = split.next()?;
1027 let index = str::parse::<usize>(&index_str.replace("p:", "")).unwrap_or(0);
1028 return Some((index, message_id.get(0..36)?));
1029 } else if guid.starts_with("bp:") {
1030 return Some((0, guid.get(3..39)?));
1031 }
1032
1033 return Some((0, guid.get(0..36)?));
1034 }
1035 None
1036 }
1037
1038 /// Parse the target body component index for a tapback.
1039 fn tapback_index(&self) -> usize {
1040 match self.clean_associated_guid() {
1041 Some((x, _)) => x,
1042 None => 0,
1043 }
1044 }
1045
1046 /// Group replies by target body component index.
1047 pub fn get_replies(
1048 &self,
1049 db: &Connection,
1050 capabilities: &Capabilities,
1051 ) -> Result<HashMap<usize, Vec<Self>>, TableError> {
1052 let mut out_h: HashMap<usize, Vec<Self>> = HashMap::new();
1053
1054 // No need to hit the DB if we know we don't have replies. A nonzero
1055 // `num_replies` also proves the schema has `thread_originator_guid`,
1056 // so the filter below always prepares.
1057 if self.has_replies() {
1058 // Use a parameterized filter so the prepared statement can be cached/reused
1059 let filters = "WHERE m.thread_originator_guid = ?1";
1060 let mut statement = prepare_message_query(db, capabilities, Some(filters))?;
1061
1062 for message in Message::rows(&mut statement, [self.guid.as_str()])? {
1063 let m = message?;
1064 let idx = m.get_reply_index();
1065 match out_h.get_mut(&idx) {
1066 Some(body_part) => body_part.push(m),
1067 None => {
1068 out_h.insert(idx, vec![m]);
1069 }
1070 }
1071 }
1072 }
1073
1074 Ok(out_h)
1075 }
1076
1077 // MARK: Polls
1078 /// Load messages that vote on or update the parent poll.
1079 pub fn get_votes(
1080 &self,
1081 db: &Connection,
1082 capabilities: &Capabilities,
1083 ) -> Result<Vec<Self>, TableError> {
1084 let mut out_v: Vec<Self> = Vec::new();
1085
1086 // No need to hit the DB if we know we don't have a poll. Polls carry
1087 // app payload data, which postdates `associated_message_guid`, so the
1088 // filter below always prepares.
1089 if self.is_poll() {
1090 // Use a parameterized filter so the prepared statement can be cached/reused
1091 let filters = "WHERE m.associated_message_guid = ?1";
1092 let mut statement = prepare_message_query(db, capabilities, Some(filters))?;
1093
1094 for message in Message::rows(&mut statement, [self.guid.as_str()])? {
1095 out_v.push(message?);
1096 }
1097 }
1098
1099 Ok(out_v)
1100 }
1101
1102 /// Parse this message as a poll, including vote counts and option updates.
1103 pub fn as_poll(
1104 &self,
1105 db: &Connection,
1106 capabilities: &Capabilities,
1107 ) -> Result<Option<Poll>, MessageError> {
1108 if self.is_poll()
1109 && let Some(payload) = self.payload_data(db)
1110 {
1111 let mut poll = Poll::from_payload(&payload)?;
1112
1113 // Get all votes associated with this poll
1114 let votes = self.get_votes(db, capabilities).unwrap_or_default();
1115
1116 // Later poll-option updates are stored as messages referencing the original poll.
1117 for vote in votes.iter().rev() {
1118 // The most recent non-vote message is the latest poll update
1119 // and contains all of the possible options
1120 if !vote.is_poll_vote()
1121 && let Some(vote_payload) = vote.payload_data(db)
1122 && let Ok(update) = Poll::from_payload(&vote_payload)
1123 {
1124 poll = update;
1125 break;
1126 }
1127 }
1128
1129 // Poll update messages share the same association field but do not cast votes.
1130 for vote in &votes {
1131 if vote.is_poll_vote()
1132 && let Some(vote_payload) = vote.payload_data(db)
1133 {
1134 poll.count_votes(&vote_payload)?;
1135 }
1136 }
1137
1138 return Ok(Some(poll));
1139 }
1140
1141 Ok(None)
1142 }
1143
1144 // MARK: Variant
1145 /// Classify the message using its associated-message fields and app balloon bundle ID.
1146 #[must_use]
1147 pub fn variant(&'_ self) -> Variant<'_> {
1148 // Edited messages expose their original type through `edited_parts`.
1149 if self.is_edited() {
1150 return Variant::Edited;
1151 }
1152
1153 // Handle different types of associated message types
1154 if let Some(associated_message_type) = self.associated_message_type {
1155 match associated_message_type {
1156 // Standard iMessages with either text or an app payload.
1157 0 | 2 | 3 => return self.get_app_variant().unwrap_or(Variant::Normal),
1158 // Tapbacks, added or removed.
1159 1000 | 2000..=2007 | 3000..=3007 => {
1160 if let Some((action, tapback)) = self.get_tapback() {
1161 return Variant::Tapback(self.tapback_index(), action, tapback);
1162 }
1163 }
1164 // A vote was cast on a poll.
1165 4000 => return Variant::Vote,
1166 x => return Variant::Unknown(x),
1167 }
1168 }
1169
1170 // Any other rarer cases belong here
1171 if self.is_shareplay() {
1172 return Variant::SharePlay;
1173 }
1174
1175 Variant::Normal
1176 }
1177
1178 /// Classify app-message variants from the balloon bundle ID.
1179 #[must_use]
1180 fn get_app_variant(&self) -> Option<Variant<'_>> {
1181 let bundle_id = parse_balloon_bundle_id(self.balloon_bundle_id.as_deref())?;
1182 let custom = match bundle_id {
1183 "com.apple.messages.URLBalloonProvider" => CustomBalloon::URL,
1184 "com.apple.Handwriting.HandwritingProvider" => CustomBalloon::Handwriting,
1185 "com.apple.DigitalTouchBalloonProvider" => CustomBalloon::DigitalTouch,
1186 "com.apple.PassbookUIService.PeerPaymentMessagesExtension" => CustomBalloon::ApplePay,
1187 "com.apple.ActivityMessagesApp.MessagesExtension" => CustomBalloon::Fitness,
1188 "com.apple.mobileslideshow.PhotosMessagesApp" => CustomBalloon::Slideshow,
1189 "com.apple.SafetyMonitorApp.SafetyMonitorMessages" => CustomBalloon::CheckIn,
1190 "com.apple.findmy.FindMyMessagesApp" => CustomBalloon::FindMy,
1191 "com.apple.icloud.apps.messages.business.extension" => CustomBalloon::Business,
1192 "com.apple.messages.Polls" => {
1193 // Special case: Check if this is the original poll or an update
1194 if self
1195 .associated_message_guid
1196 .as_ref()
1197 .is_none_or(|id| id == &self.guid)
1198 {
1199 CustomBalloon::Polls
1200 } else {
1201 return Some(Variant::PollUpdate);
1202 }
1203 }
1204 _ => CustomBalloon::Application(bundle_id),
1205 };
1206 Some(Variant::App(custom))
1207 }
1208
1209 /// Classify tapback action and type from the associated message type.
1210 #[must_use]
1211 fn get_tapback(&self) -> Option<(TapbackAction, Tapback<'_>)> {
1212 match self.associated_message_type? {
1213 1000 => Some((TapbackAction::Added, Tapback::Sticker)),
1214 2000 => Some((TapbackAction::Added, Tapback::Loved)),
1215 2001 => Some((TapbackAction::Added, Tapback::Liked)),
1216 2002 => Some((TapbackAction::Added, Tapback::Disliked)),
1217 2003 => Some((TapbackAction::Added, Tapback::Laughed)),
1218 2004 => Some((TapbackAction::Added, Tapback::Emphasized)),
1219 2005 => Some((TapbackAction::Added, Tapback::Questioned)),
1220 2006 => Some((
1221 TapbackAction::Added,
1222 Tapback::Emoji(self.associated_message_emoji.as_deref()),
1223 )),
1224 2007 => Some((TapbackAction::Added, Tapback::Sticker)),
1225 3000 => Some((TapbackAction::Removed, Tapback::Loved)),
1226 3001 => Some((TapbackAction::Removed, Tapback::Liked)),
1227 3002 => Some((TapbackAction::Removed, Tapback::Disliked)),
1228 3003 => Some((TapbackAction::Removed, Tapback::Laughed)),
1229 3004 => Some((TapbackAction::Removed, Tapback::Emphasized)),
1230 3005 => Some((TapbackAction::Removed, Tapback::Questioned)),
1231 3006 => Some((
1232 TapbackAction::Removed,
1233 Tapback::Emoji(self.associated_message_emoji.as_deref()),
1234 )),
1235 3007 => Some((TapbackAction::Removed, Tapback::Sticker)),
1236 _ => None,
1237 }
1238 }
1239
1240 /// Parse the announcement represented by this message.
1241 #[must_use]
1242 pub fn get_announcement(&'_ self) -> Option<Announcement<'_>> {
1243 if let Some(action) = self.group_action() {
1244 return Some(Announcement::GroupAction(action));
1245 }
1246
1247 if self.is_fully_unsent() {
1248 return Some(Announcement::FullyUnsent);
1249 }
1250
1251 if self.is_kept_audio_message() {
1252 return Some(Announcement::AudioMessageKept);
1253 }
1254
1255 None
1256 }
1257
1258 /// Parse the message service.
1259 #[must_use]
1260 pub fn service(&'_ self) -> Service<'_> {
1261 Service::from_name(self.service.as_deref())
1262 }
1263
1264 /// Parse the message's raw filter category.
1265 ///
1266 /// A raw `0` maps to [`FilterAction::Unfiltered`]; an absent or `NULL` value
1267 /// maps to `None`.
1268 #[must_use]
1269 pub fn filter_action(&self) -> Option<FilterAction> {
1270 FilterAction::from_code(self.filter_action)
1271 }
1272
1273 // MARK: BLOBs
1274 /// Parse the [`MESSAGE_PAYLOAD`] `BLOB` column as a property list.
1275 ///
1276 /// Calling this reads a `BLOB` from the database.
1277 ///
1278 /// This column contains data used by iMessage app balloons and can be parsed with
1279 /// [`parse_ns_keyed_archiver()`](crate::util::plist::parse_ns_keyed_archiver).
1280 pub fn payload_data(&self, db: &Connection) -> Option<Value> {
1281 // Read the blob into memory first, then parse from a `Cursor`.
1282 Value::from_reader(Cursor::new(self.raw_payload_data(db)?)).ok()
1283 }
1284
1285 /// Read the raw [`MESSAGE_PAYLOAD`] `BLOB` bytes.
1286 ///
1287 /// Calling this reads a `BLOB` from the database.
1288 ///
1289 /// This column contains data used by [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage)s.
1290 pub fn raw_payload_data(&self, db: &Connection) -> Option<Vec<u8>> {
1291 let mut buf = Vec::new();
1292 open_blob(db, MESSAGE, MESSAGE_PAYLOAD, self.rowid.into())?
1293 .read_to_end(&mut buf)
1294 .ok()?;
1295 Some(buf)
1296 }
1297
1298 /// Parse the [`MESSAGE_SUMMARY_INFO`] `BLOB` column as a property list.
1299 ///
1300 /// Calling this reads a `BLOB` from the database.
1301 ///
1302 /// This column contains data used by [`edited`](crate::message_types::edited) iMessages.
1303 pub fn message_summary_info(&self, db: &Connection) -> Option<Value> {
1304 // Bulk-read the blob, then parse from memory.
1305 let mut buf = Vec::new();
1306 open_blob(db, MESSAGE, MESSAGE_SUMMARY_INFO, self.rowid.into())?
1307 .read_to_end(&mut buf)
1308 .ok()?;
1309 Value::from_reader(Cursor::new(buf)).ok()
1310 }
1311
1312 // MARK: Expressive
1313 /// Parse the expressive send effect.
1314 #[must_use]
1315 pub fn get_expressive(&'_ self) -> Expressive<'_> {
1316 match &self.expressive_send_style_id {
1317 Some(content) => match content.as_str() {
1318 "com.apple.MobileSMS.expressivesend.gentle" => {
1319 Expressive::Bubble(BubbleEffect::Gentle)
1320 }
1321 "com.apple.MobileSMS.expressivesend.impact" => {
1322 Expressive::Bubble(BubbleEffect::Slam)
1323 }
1324 "com.apple.MobileSMS.expressivesend.invisibleink" => {
1325 Expressive::Bubble(BubbleEffect::InvisibleInk)
1326 }
1327 "com.apple.MobileSMS.expressivesend.loud" => Expressive::Bubble(BubbleEffect::Loud),
1328 "com.apple.messages.effect.CKConfettiEffect" => {
1329 Expressive::Screen(ScreenEffect::Confetti)
1330 }
1331 "com.apple.messages.effect.CKEchoEffect" => Expressive::Screen(ScreenEffect::Echo),
1332 "com.apple.messages.effect.CKFireworksEffect" => {
1333 Expressive::Screen(ScreenEffect::Fireworks)
1334 }
1335 "com.apple.messages.effect.CKHappyBirthdayEffect" => {
1336 Expressive::Screen(ScreenEffect::Balloons)
1337 }
1338 "com.apple.messages.effect.CKHeartEffect" => {
1339 Expressive::Screen(ScreenEffect::Heart)
1340 }
1341 "com.apple.messages.effect.CKLasersEffect" => {
1342 Expressive::Screen(ScreenEffect::Lasers)
1343 }
1344 "com.apple.messages.effect.CKShootingStarEffect" => {
1345 Expressive::Screen(ScreenEffect::ShootingStar)
1346 }
1347 "com.apple.messages.effect.CKSparklesEffect" => {
1348 Expressive::Screen(ScreenEffect::Sparkles)
1349 }
1350 "com.apple.messages.effect.CKSpotlightEffect" => {
1351 Expressive::Screen(ScreenEffect::Spotlight)
1352 }
1353 _ => Expressive::Unknown(content),
1354 },
1355 None => Expressive::None,
1356 }
1357 }
1358
1359 /// Query a single message by [`GUID`](Self::guid).
1360 ///
1361 /// The returned message includes parsed [`components`](Self::components)
1362 /// and [`edited_parts`](Self::edited_parts) when available. Body parsing
1363 /// failures preserve the row's metadata.
1364 ///
1365 /// # Example
1366 /// ```no_run
1367 /// use imessage_database::{
1368 /// tables::{
1369 /// capabilities::Capabilities,
1370 /// messages::Message,
1371 /// table::get_connection,
1372 /// },
1373 /// util::dirs::default_db_path,
1374 /// };
1375 ///
1376 /// let db_path = default_db_path().unwrap();
1377 /// let conn = get_connection(&db_path).unwrap();
1378 /// let capabilities = Capabilities::determine(&conn).unwrap();
1379 ///
1380 /// if let Ok(message) = Message::from_guid("example-guid", &conn, &capabilities) {
1381 /// println!("Text: {:?}", message.text);
1382 /// println!("Body components: {:#?}", message.components);
1383 /// println!("Edit history: {:#?}", message.edited_parts);
1384 /// }
1385 /// ```
1386 pub fn from_guid(
1387 guid: &str,
1388 db: &Connection,
1389 capabilities: &Capabilities,
1390 ) -> Result<Self, TableError> {
1391 let mut statement = prepare_message_query(db, capabilities, Some("WHERE m.guid = ?1"))?;
1392
1393 Message::row(&mut statement, [guid])
1394 }
1395}
1396
1397// MARK: Fixture
1398#[cfg(test)]
1399impl Message {
1400 #[must_use]
1401 /// Build a blank test message with default values.
1402 pub fn blank() -> Message {
1403 use std::vec;
1404
1405 Message {
1406 rowid: i32::default(),
1407 guid: String::default(),
1408 text: None,
1409 service: Some("iMessage".to_string()),
1410 handle_id: Some(i32::default()),
1411 destination_caller_id: None,
1412 subject: None,
1413 date: i64::default(),
1414 date_read: i64::default(),
1415 date_delivered: i64::default(),
1416 is_from_me: false,
1417 is_read: false,
1418 item_type: 0,
1419 other_handle: None,
1420 share_status: false,
1421 share_direction: None,
1422 group_title: None,
1423 group_action_type: 0,
1424 associated_message_guid: None,
1425 associated_message_type: None,
1426 balloon_bundle_id: None,
1427 expressive_send_style_id: None,
1428 thread_originator_guid: None,
1429 thread_originator_part: None,
1430 date_edited: 0,
1431 associated_message_emoji: None,
1432 chat_id: None,
1433 num_attachments: 0,
1434 deleted_from: None,
1435 num_replies: 0,
1436 filter_action: None,
1437 filter_sub_action: None,
1438 components: vec![],
1439 edited_parts: None,
1440 }
1441 }
1442}
1443
1444#[cfg(test)]
1445mod diagnostic_tests {
1446 use rusqlite::Connection;
1447
1448 use crate::tables::messages::Message;
1449
1450 fn diagnostic_db() -> Connection {
1451 let db = Connection::open_in_memory().unwrap();
1452 db.execute_batch(
1453 "
1454 CREATE TABLE message (
1455 ROWID INTEGER PRIMARY KEY,
1456 date INTEGER
1457 );
1458 CREATE TABLE chat_message_join (
1459 chat_id INTEGER,
1460 message_id INTEGER
1461 );
1462 INSERT INTO message (ROWID, date) VALUES (1, 10), (2, 20);
1463 INSERT INTO chat_message_join (chat_id, message_id) VALUES (1, 1);
1464 ",
1465 )
1466 .unwrap();
1467 db
1468 }
1469
1470 #[test]
1471 fn diagnostic_omits_recoverable_count_when_table_is_missing() {
1472 let db = diagnostic_db();
1473
1474 let diagnostic = Message::run_diagnostic(&db).unwrap();
1475
1476 assert_eq!(diagnostic.total_messages, 2);
1477 assert_eq!(diagnostic.messages_without_chat, 1);
1478 assert_eq!(diagnostic.recoverable_messages, None);
1479 }
1480
1481 #[test]
1482 fn diagnostic_counts_recoverable_messages_when_table_exists() {
1483 let db = diagnostic_db();
1484 db.execute_batch(
1485 "
1486 CREATE TABLE chat_recoverable_message_join (
1487 chat_id INTEGER,
1488 message_id INTEGER
1489 );
1490 INSERT INTO chat_recoverable_message_join (chat_id, message_id) VALUES (1, 2);
1491 ",
1492 )
1493 .unwrap();
1494
1495 let diagnostic = Message::run_diagnostic(&db).unwrap();
1496
1497 assert_eq!(diagnostic.recoverable_messages, Some(1));
1498 }
1499}