Skip to main content

imessage_database/tables/messages/
message.rs

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