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 * 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_without_recognized_key() {
195        let balloon = check_in_msg("?messageType=1&interfaceVersion=1");
196        assert!(balloon.check_in_kind(0).is_none());
197    }
198
199    #[test]
200    fn test_parse_apple_pay_sent_265() {
201        let plist_path = current_dir()
202            .unwrap()
203            .as_path()
204            .join("test_data/app_message/Sent265.plist");
205        let plist_data = File::open(plist_path).unwrap();
206        let plist = Value::from_reader(plist_data).unwrap();
207        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
208
209        let balloon = AppMessage::from_map(&parsed).unwrap();
210        let expected = AppMessage {
211            image: None,
212            url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
213            title: None,
214            subtitle: None,
215            caption: Some("Apple\u{a0}Cash"),
216            subcaption: Some("$265\u{a0}Payment"),
217            trailing_caption: None,
218            trailing_subcaption: None,
219            app_name: Some("Apple\u{a0}Pay"),
220            ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
221        };
222
223        assert_eq!(balloon, expected);
224    }
225
226    #[test]
227    fn test_parse_apple_pay_recurring_1() {
228        let plist_path = current_dir()
229            .unwrap()
230            .as_path()
231            .join("test_data/app_message/ApplePayRecurring.plist");
232        let plist_data = File::open(plist_path).unwrap();
233        let plist = Value::from_reader(plist_data).unwrap();
234        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
235
236        let balloon = AppMessage::from_map(&parsed).unwrap();
237        let expected = AppMessage {
238            image: None,
239            url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
240            title: None,
241            subtitle: None,
242            caption: None,
243            subcaption: None,
244            trailing_caption: None,
245            trailing_subcaption: None,
246            app_name: Some("Apple\u{a0}Cash"),
247            ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
248        };
249
250        assert_eq!(balloon, expected);
251    }
252
253    #[test]
254    fn test_parse_opentable_invite() {
255        let plist_path = current_dir()
256            .unwrap()
257            .as_path()
258            .join("test_data/app_message/OpenTableInvited.plist");
259        let plist_data = File::open(plist_path).unwrap();
260        let plist = Value::from_reader(plist_data).unwrap();
261        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
262
263        let balloon = AppMessage::from_map(&parsed).unwrap();
264        let expected = AppMessage {
265            image: None,
266            url: Some(
267                "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
268            ),
269            title: Some("Rusty Grill - Boise"),
270            subtitle: Some("Reservation Confirmed"),
271            caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
272            subcaption: Some("You're invited! Tap to accept."),
273            trailing_caption: None,
274            trailing_subcaption: None,
275            app_name: Some("OpenTable"),
276            ldtext: None,
277        };
278
279        assert_eq!(balloon, expected);
280    }
281
282    #[test]
283    fn test_parse_slideshow() {
284        let plist_path = current_dir()
285            .unwrap()
286            .as_path()
287            .join("test_data/app_message/Slideshow.plist");
288        let plist_data = File::open(plist_path).unwrap();
289        let plist = Value::from_reader(plist_data).unwrap();
290        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
291
292        let balloon = AppMessage::from_map(&parsed).unwrap();
293        let expected = AppMessage {
294            image: None,
295            url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
296            title: None,
297            subtitle: None,
298            caption: Some("Home"),
299            subcaption: Some("37 Photos"),
300            trailing_caption: None,
301            trailing_subcaption: None,
302            app_name: Some("Photos"),
303            ldtext: Some("Home - 37 Photos"),
304        };
305
306        assert_eq!(balloon, expected);
307    }
308
309    #[test]
310    fn test_parse_game() {
311        let plist_path = current_dir()
312            .unwrap()
313            .as_path()
314            .join("test_data/app_message/Game.plist");
315        let plist_data = File::open(plist_path).unwrap();
316        let plist = Value::from_reader(plist_data).unwrap();
317        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
318
319        let balloon = AppMessage::from_map(&parsed).unwrap();
320        let expected = AppMessage {
321            image: None,
322            url: Some("data:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
323            title: None,
324            subtitle: None,
325            caption: Some("Your move."),
326            subcaption: None,
327            trailing_caption: None,
328            trailing_subcaption: None,
329            app_name: Some("GamePigeon"),
330            ldtext: Some("Dots & Boxes"),
331        };
332
333        assert_eq!(balloon, expected);
334    }
335
336    #[test]
337    fn test_parse_business() {
338        let plist_path = current_dir()
339            .unwrap()
340            .as_path()
341            .join("test_data/app_message/Business.plist");
342        let plist_data = File::open(plist_path).unwrap();
343        let plist = Value::from_reader(plist_data).unwrap();
344        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
345
346        let balloon = AppMessage::from_map(&parsed).unwrap();
347        let expected = AppMessage {
348            image: None,
349            url: Some(
350                "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
351            ),
352            title: None,
353            subtitle: None,
354            caption: Some("Yes, connect me with Goldman Sachs."),
355            subcaption: None,
356            trailing_caption: None,
357            trailing_subcaption: None,
358            app_name: Some("Business"),
359            ldtext: Some("Yes, connect me with Goldman Sachs."),
360        };
361
362        assert_eq!(balloon, expected);
363    }
364
365    #[test]
366    fn test_parse_business_query_string() {
367        let plist_path = current_dir()
368            .unwrap()
369            .as_path()
370            .join("test_data/app_message/Business.plist");
371        let plist_data = File::open(plist_path).unwrap();
372        let plist = Value::from_reader(plist_data).unwrap();
373        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
374
375        let balloon = AppMessage::from_map(&parsed).unwrap();
376        let mut expected = HashMap::new();
377        expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
378        expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
379
380        assert_eq!(balloon.parse_query_string(), expected);
381    }
382
383    #[test]
384    fn test_parse_check_in_timer() {
385        let plist_path = current_dir()
386            .unwrap()
387            .as_path()
388            .join("test_data/app_message/CheckinTimer.plist");
389        let plist_data = File::open(plist_path).unwrap();
390        let plist = Value::from_reader(plist_data).unwrap();
391        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
392
393        let balloon = AppMessage::from_map(&parsed).unwrap();
394
395        let expected = AppMessage {
396            image: None,
397            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
398            title: None,
399            subtitle: None,
400            caption: Some("Check In: Timer Started"),
401            subcaption: None,
402            trailing_caption: None,
403            trailing_subcaption: None,
404            app_name: Some("Check In"),
405            ldtext: Some("Check In: Timer Started"),
406        };
407
408        assert_eq!(balloon, expected);
409    }
410
411    #[test]
412    fn test_parse_check_in_timer_late() {
413        let plist_path = current_dir()
414            .unwrap()
415            .as_path()
416            .join("test_data/app_message/CheckinLate.plist");
417        let plist_data = File::open(plist_path).unwrap();
418        let plist = Value::from_reader(plist_data).unwrap();
419        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
420
421        let balloon = AppMessage::from_map(&parsed).unwrap();
422
423        let expected = AppMessage {
424            image: None,
425            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
426            title: None,
427            subtitle: None,
428            caption: Some("Check In: Has not checked in when expected, location shared"),
429            subcaption: None,
430            trailing_caption: None,
431            trailing_subcaption: None,
432            app_name: Some("Check In"),
433            ldtext: Some("Check In: Has not checked in when expected, location shared"),
434        };
435
436        assert_eq!(balloon, expected);
437    }
438
439    #[test]
440    fn test_parse_check_in_location() {
441        let plist_path = current_dir()
442            .unwrap()
443            .as_path()
444            .join("test_data/app_message/CheckinLocation.plist");
445        let plist_data = File::open(plist_path).unwrap();
446        let plist = Value::from_reader(plist_data).unwrap();
447        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
448
449        let balloon = AppMessage::from_map(&parsed).unwrap();
450
451        let expected = AppMessage {
452            image: None,
453            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
454            title: None,
455            subtitle: None,
456            caption: Some("Check In: Fake Location"),
457            subcaption: None,
458            trailing_caption: None,
459            trailing_subcaption: None,
460            app_name: Some("Check In"),
461            ldtext: Some("Check In: Fake Location"),
462        };
463
464        assert_eq!(balloon, expected);
465    }
466
467    #[test]
468    fn test_parse_check_in_query_string() {
469        let plist_path = current_dir()
470            .unwrap()
471            .as_path()
472            .join("test_data/app_message/CheckinTimer.plist");
473        let plist_data = File::open(plist_path).unwrap();
474        let plist = Value::from_reader(plist_data).unwrap();
475        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
476
477        let balloon = AppMessage::from_map(&parsed).unwrap();
478        let mut expected = HashMap::new();
479        expected.insert("messageType", "1");
480        expected.insert("interfaceVersion", "1");
481        expected.insert("sendDate", "1697316869.688709");
482
483        assert_eq!(balloon.parse_query_string(), expected);
484    }
485
486    #[test]
487    fn test_parse_find_my() {
488        let plist_path = current_dir()
489            .unwrap()
490            .as_path()
491            .join("test_data/app_message/FindMy.plist");
492        let plist_data = File::open(plist_path).unwrap();
493        let plist = Value::from_reader(plist_data).unwrap();
494        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
495
496        let balloon = AppMessage::from_map(&parsed).unwrap();
497        let expected = AppMessage {
498            image: None,
499            url: Some(
500                "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
501            ),
502            title: None,
503            subtitle: None,
504            caption: None,
505            subcaption: None,
506            trailing_caption: None,
507            trailing_subcaption: None,
508            app_name: Some("Find My"),
509            ldtext: Some("Started Sharing Location"),
510        };
511
512        assert_eq!(balloon, expected);
513    }
514}