Skip to main content

imessage_database/message_types/
app.rs

1/*!
2  App messages are messages that developers can generate with their apps.
3  Some built-in functionality also uses App Messages, like Apple Pay or Handwriting.
4*/
5
6use std::collections::HashMap;
7
8use chrono::{DateTime, Local};
9use plist::Value;
10
11use crate::{
12    error::plist::PlistParseError,
13    message_types::variants::BalloonProvider,
14    util::{
15        dates::{TIMESTAMP_FACTOR, get_local_time},
16        plist::{get_string_from_dict, get_string_from_nested_dict},
17    },
18};
19
20/// This struct represents Apple's [`MSMessageTemplateLayout`](https://developer.apple.com/documentation/messages/msmessagetemplatelayout).
21#[derive(Debug, PartialEq, Eq)]
22pub struct AppMessage<'a> {
23    /// An image used to represent the message in the transcript
24    pub image: Option<&'a str>,
25    /// A URL pointing to a media file used to represent the message in the transcript
26    pub url: Option<&'a str>,
27    /// The title for the image or media file
28    pub title: Option<&'a str>,
29    /// The subtitle for the image or media file
30    pub subtitle: Option<&'a str>,
31    /// A left-aligned caption for the message bubble
32    pub caption: Option<&'a str>,
33    /// A left-aligned subcaption for the message bubble
34    pub subcaption: Option<&'a str>,
35    /// A right-aligned caption for the message bubble
36    pub trailing_caption: Option<&'a str>,
37    /// A right-aligned subcaption for the message bubble
38    pub trailing_subcaption: Option<&'a str>,
39    /// The name of the app that created this message
40    pub app_name: Option<&'a str>,
41    /// This property is set only for Apple system messages,
42    /// it represents the text that displays in the center of the bubble
43    pub ldtext: Option<&'a str>,
44}
45
46impl<'a> BalloonProvider<'a> for AppMessage<'a> {
47    fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
48        let user_info = payload
49            .as_dictionary()
50            .ok_or_else(|| {
51                PlistParseError::InvalidType("root".to_string(), "dictionary".to_string())
52            })?
53            .get("userInfo")
54            .ok_or_else(|| PlistParseError::MissingKey("userInfo".to_string()))?;
55        Ok(AppMessage {
56            image: get_string_from_dict(payload, "image"),
57            url: get_string_from_nested_dict(payload, "URL"),
58            title: get_string_from_dict(user_info, "image-title"),
59            subtitle: get_string_from_dict(user_info, "image-subtitle"),
60            caption: get_string_from_dict(user_info, "caption"),
61            subcaption: get_string_from_dict(user_info, "subcaption"),
62            trailing_caption: get_string_from_dict(user_info, "secondary-subcaption"),
63            trailing_subcaption: get_string_from_dict(user_info, "tertiary-subcaption"),
64            app_name: get_string_from_dict(payload, "an"),
65            ldtext: get_string_from_dict(payload, "ldtext"),
66        })
67    }
68}
69
70impl AppMessage<'_> {
71    /// Parse key/value pairs from the query string in the balloon's URL
72    #[must_use]
73    pub fn parse_query_string(&self) -> HashMap<&str, &str> {
74        let mut map = HashMap::new();
75
76        if let Some(url) = self.url
77            && url.starts_with('?')
78        {
79            let parts = url.strip_prefix('?').unwrap_or(url).split('&');
80            for part in parts {
81                let key_val_split: Vec<&str> = part.split('=').collect();
82                if key_val_split.len() == 2 {
83                    map.insert(key_val_split[0], key_val_split[1]);
84                }
85            }
86        }
87        map
88    }
89
90    /// Identifies the metadata state of a Check In balloon and resolves its
91    /// associated timestamp to local time. Returns `None` when no recognized
92    /// Check In key is present in [`parse_query_string`](Self::parse_query_string)
93    /// or the value isn't a parseable iMessage timestamp.
94    ///
95    /// `offset` is the seconds adjustment to apply to the iMessage epoch when
96    /// converting to local time: pass `0` to use the system's current
97    /// timezone, or a [`get_offset`](crate::util::dates::get_offset)-derived
98    /// value when reading a database exported from a different timezone.
99    #[must_use]
100    pub fn check_in_kind(&self, offset: i64) -> Option<(CheckInKind, DateTime<Local>)> {
101        let metadata = self.parse_query_string();
102        let (kind, date_str) = if let Some(d) = metadata.get("estimatedEndTime") {
103            (CheckInKind::Expected, *d)
104        } else if let Some(d) = metadata.get("triggerTime") {
105            (CheckInKind::WasExpected, *d)
106        } else if let Some(d) = metadata.get("sendDate") {
107            (CheckInKind::CheckedIn, *d)
108        } else {
109            return None;
110        };
111        let date_stamp = (date_str.parse::<f64>().ok()? as i64).checked_mul(TIMESTAMP_FACTOR)?;
112        let date_time = get_local_time(date_stamp, offset).ok()?;
113        Some((kind, date_time))
114    }
115}
116
117/// One of the three metadata states a Check In balloon can advertise. The
118/// variant choice mirrors the query-string key the timestamp came from
119/// (`estimatedEndTime`, `triggerTime`, `sendDate`).
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CheckInKind {
122    /// `estimatedEndTime`: Check In is scheduled and still pending.
123    Expected,
124    /// `triggerTime`: Check In window has passed without confirmation.
125    WasExpected,
126    /// `sendDate`: Check In was manually confirmed.
127    CheckedIn,
128}
129
130#[cfg(test)]
131mod tests {
132    use crate::{
133        message_types::{
134            app::{AppMessage, CheckInKind},
135            variants::BalloonProvider,
136        },
137        util::plist::parse_ns_keyed_archiver,
138    };
139    use plist::Value;
140    use std::fs::File;
141    use std::{collections::HashMap, env::current_dir};
142
143    fn check_in_msg(url: &str) -> AppMessage<'_> {
144        AppMessage {
145            image: None,
146            url: Some(url),
147            title: None,
148            subtitle: None,
149            caption: None,
150            subcaption: None,
151            trailing_caption: None,
152            trailing_subcaption: None,
153            app_name: Some("Check In"),
154            ldtext: None,
155        }
156    }
157
158    #[test]
159    fn check_in_kind_prefers_estimated_end_time() {
160        let balloon = check_in_msg(
161            "?estimatedEndTime=1697316869.688709&triggerTime=1697316869.688709&sendDate=1697316869.688709",
162        );
163        assert!(matches!(
164            balloon.check_in_kind(0),
165            Some((CheckInKind::Expected, _)),
166        ));
167    }
168
169    #[test]
170    fn check_in_kind_falls_back_to_trigger_time() {
171        let balloon = check_in_msg("?triggerTime=1697316869.688709&sendDate=1697316869.688709");
172        assert!(matches!(
173            balloon.check_in_kind(0),
174            Some((CheckInKind::WasExpected, _)),
175        ));
176    }
177
178    #[test]
179    fn check_in_kind_uses_send_date_when_only_option() {
180        let balloon = check_in_msg("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709");
181        assert!(matches!(
182            balloon.check_in_kind(0),
183            Some((CheckInKind::CheckedIn, _)),
184        ));
185    }
186
187    #[test]
188    fn check_in_kind_returns_none_for_unparsable_timestamp() {
189        let balloon = check_in_msg("?sendDate=not_a_number");
190        assert!(balloon.check_in_kind(0).is_none());
191    }
192
193    #[test]
194    fn check_in_kind_returns_none_for_overflowing_timestamp() {
195        // The nanosecond conversion (`seconds * 1_000_000_000`) overflows i64 for
196        // this value; it must degrade to None rather than panic (debug) or wrap to
197        // a bogus date (release).
198        let balloon = check_in_msg("?sendDate=99999999999");
199        assert!(balloon.check_in_kind(0).is_none());
200    }
201
202    #[test]
203    fn check_in_kind_returns_none_without_recognized_key() {
204        let balloon = check_in_msg("?messageType=1&interfaceVersion=1");
205        assert!(balloon.check_in_kind(0).is_none());
206    }
207
208    #[test]
209    fn test_parse_apple_pay_sent_265() {
210        let plist_path = current_dir()
211            .unwrap()
212            .as_path()
213            .join("test_data/app_message/Sent265.plist");
214        let plist_data = File::open(plist_path).unwrap();
215        let plist = Value::from_reader(plist_data).unwrap();
216        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
217
218        let balloon = AppMessage::from_map(&parsed).unwrap();
219        let expected = AppMessage {
220            image: None,
221            url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
222            title: None,
223            subtitle: None,
224            caption: Some("Apple\u{a0}Cash"),
225            subcaption: Some("$265\u{a0}Payment"),
226            trailing_caption: None,
227            trailing_subcaption: None,
228            app_name: Some("Apple\u{a0}Pay"),
229            ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
230        };
231
232        assert_eq!(balloon, expected);
233    }
234
235    #[test]
236    fn test_parse_apple_pay_recurring_1() {
237        let plist_path = current_dir()
238            .unwrap()
239            .as_path()
240            .join("test_data/app_message/ApplePayRecurring.plist");
241        let plist_data = File::open(plist_path).unwrap();
242        let plist = Value::from_reader(plist_data).unwrap();
243        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
244
245        let balloon = AppMessage::from_map(&parsed).unwrap();
246        let expected = AppMessage {
247            image: None,
248            url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
249            title: None,
250            subtitle: None,
251            caption: None,
252            subcaption: None,
253            trailing_caption: None,
254            trailing_subcaption: None,
255            app_name: Some("Apple\u{a0}Cash"),
256            ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
257        };
258
259        assert_eq!(balloon, expected);
260    }
261
262    #[test]
263    fn test_parse_opentable_invite() {
264        let plist_path = current_dir()
265            .unwrap()
266            .as_path()
267            .join("test_data/app_message/OpenTableInvited.plist");
268        let plist_data = File::open(plist_path).unwrap();
269        let plist = Value::from_reader(plist_data).unwrap();
270        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
271
272        let balloon = AppMessage::from_map(&parsed).unwrap();
273        let expected = AppMessage {
274            image: None,
275            url: Some(
276                "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
277            ),
278            title: Some("Rusty Grill - Boise"),
279            subtitle: Some("Reservation Confirmed"),
280            caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
281            subcaption: Some("You're invited! Tap to accept."),
282            trailing_caption: None,
283            trailing_subcaption: None,
284            app_name: Some("OpenTable"),
285            ldtext: None,
286        };
287
288        assert_eq!(balloon, expected);
289    }
290
291    #[test]
292    fn test_parse_slideshow() {
293        let plist_path = current_dir()
294            .unwrap()
295            .as_path()
296            .join("test_data/app_message/Slideshow.plist");
297        let plist_data = File::open(plist_path).unwrap();
298        let plist = Value::from_reader(plist_data).unwrap();
299        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
300
301        let balloon = AppMessage::from_map(&parsed).unwrap();
302        let expected = AppMessage {
303            image: None,
304            url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
305            title: None,
306            subtitle: None,
307            caption: Some("Home"),
308            subcaption: Some("37 Photos"),
309            trailing_caption: None,
310            trailing_subcaption: None,
311            app_name: Some("Photos"),
312            ldtext: Some("Home - 37 Photos"),
313        };
314
315        assert_eq!(balloon, expected);
316    }
317
318    #[test]
319    fn test_parse_game() {
320        let plist_path = current_dir()
321            .unwrap()
322            .as_path()
323            .join("test_data/app_message/Game.plist");
324        let plist_data = File::open(plist_path).unwrap();
325        let plist = Value::from_reader(plist_data).unwrap();
326        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
327
328        let balloon = AppMessage::from_map(&parsed).unwrap();
329        let expected = AppMessage {
330            image: None,
331            url: Some("data:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
332            title: None,
333            subtitle: None,
334            caption: Some("Your move."),
335            subcaption: None,
336            trailing_caption: None,
337            trailing_subcaption: None,
338            app_name: Some("GamePigeon"),
339            ldtext: Some("Dots & Boxes"),
340        };
341
342        assert_eq!(balloon, expected);
343    }
344
345    #[test]
346    fn test_parse_business() {
347        let plist_path = current_dir()
348            .unwrap()
349            .as_path()
350            .join("test_data/app_message/Business.plist");
351        let plist_data = File::open(plist_path).unwrap();
352        let plist = Value::from_reader(plist_data).unwrap();
353        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
354
355        let balloon = AppMessage::from_map(&parsed).unwrap();
356        let expected = AppMessage {
357            image: None,
358            url: Some(
359                "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
360            ),
361            title: None,
362            subtitle: None,
363            caption: Some("Yes, connect me with Goldman Sachs."),
364            subcaption: None,
365            trailing_caption: None,
366            trailing_subcaption: None,
367            app_name: Some("Business"),
368            ldtext: Some("Yes, connect me with Goldman Sachs."),
369        };
370
371        assert_eq!(balloon, expected);
372    }
373
374    #[test]
375    fn test_parse_business_query_string() {
376        let plist_path = current_dir()
377            .unwrap()
378            .as_path()
379            .join("test_data/app_message/Business.plist");
380        let plist_data = File::open(plist_path).unwrap();
381        let plist = Value::from_reader(plist_data).unwrap();
382        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
383
384        let balloon = AppMessage::from_map(&parsed).unwrap();
385        let mut expected = HashMap::new();
386        expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
387        expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
388
389        assert_eq!(balloon.parse_query_string(), expected);
390    }
391
392    #[test]
393    fn test_parse_check_in_timer() {
394        let plist_path = current_dir()
395            .unwrap()
396            .as_path()
397            .join("test_data/app_message/CheckinTimer.plist");
398        let plist_data = File::open(plist_path).unwrap();
399        let plist = Value::from_reader(plist_data).unwrap();
400        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
401
402        let balloon = AppMessage::from_map(&parsed).unwrap();
403
404        let expected = AppMessage {
405            image: None,
406            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
407            title: None,
408            subtitle: None,
409            caption: Some("Check In: Timer Started"),
410            subcaption: None,
411            trailing_caption: None,
412            trailing_subcaption: None,
413            app_name: Some("Check In"),
414            ldtext: Some("Check In: Timer Started"),
415        };
416
417        assert_eq!(balloon, expected);
418    }
419
420    #[test]
421    fn test_parse_check_in_timer_late() {
422        let plist_path = current_dir()
423            .unwrap()
424            .as_path()
425            .join("test_data/app_message/CheckinLate.plist");
426        let plist_data = File::open(plist_path).unwrap();
427        let plist = Value::from_reader(plist_data).unwrap();
428        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
429
430        let balloon = AppMessage::from_map(&parsed).unwrap();
431
432        let expected = AppMessage {
433            image: None,
434            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
435            title: None,
436            subtitle: None,
437            caption: Some("Check In: Has not checked in when expected, location shared"),
438            subcaption: None,
439            trailing_caption: None,
440            trailing_subcaption: None,
441            app_name: Some("Check In"),
442            ldtext: Some("Check In: Has not checked in when expected, location shared"),
443        };
444
445        assert_eq!(balloon, expected);
446    }
447
448    #[test]
449    fn test_parse_check_in_location() {
450        let plist_path = current_dir()
451            .unwrap()
452            .as_path()
453            .join("test_data/app_message/CheckinLocation.plist");
454        let plist_data = File::open(plist_path).unwrap();
455        let plist = Value::from_reader(plist_data).unwrap();
456        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
457
458        let balloon = AppMessage::from_map(&parsed).unwrap();
459
460        let expected = AppMessage {
461            image: None,
462            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
463            title: None,
464            subtitle: None,
465            caption: Some("Check In: Fake Location"),
466            subcaption: None,
467            trailing_caption: None,
468            trailing_subcaption: None,
469            app_name: Some("Check In"),
470            ldtext: Some("Check In: Fake Location"),
471        };
472
473        assert_eq!(balloon, expected);
474    }
475
476    #[test]
477    fn test_parse_check_in_query_string() {
478        let plist_path = current_dir()
479            .unwrap()
480            .as_path()
481            .join("test_data/app_message/CheckinTimer.plist");
482        let plist_data = File::open(plist_path).unwrap();
483        let plist = Value::from_reader(plist_data).unwrap();
484        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
485
486        let balloon = AppMessage::from_map(&parsed).unwrap();
487        let mut expected = HashMap::new();
488        expected.insert("messageType", "1");
489        expected.insert("interfaceVersion", "1");
490        expected.insert("sendDate", "1697316869.688709");
491
492        assert_eq!(balloon.parse_query_string(), expected);
493    }
494
495    #[test]
496    fn test_parse_find_my() {
497        let plist_path = current_dir()
498            .unwrap()
499            .as_path()
500            .join("test_data/app_message/FindMy.plist");
501        let plist_data = File::open(plist_path).unwrap();
502        let plist = Value::from_reader(plist_data).unwrap();
503        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
504
505        let balloon = AppMessage::from_map(&parsed).unwrap();
506        let expected = AppMessage {
507            image: None,
508            url: Some(
509                "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
510            ),
511            title: None,
512            subtitle: None,
513            caption: None,
514            subcaption: None,
515            trailing_caption: None,
516            trailing_subcaption: None,
517            app_name: Some("Find My"),
518            ldtext: Some("Started Sharing Location"),
519        };
520
521        assert_eq!(balloon, expected);
522    }
523}