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 {
107            let d = metadata.get("sendDate")?;
108            (CheckInKind::CheckedIn, *d)
109        };
110        let date_stamp = (date_str.parse::<f64>().ok()? as i64).checked_mul(TIMESTAMP_FACTOR)?;
111        let date_time = get_local_time(date_stamp, offset).ok()?;
112        Some((kind, date_time))
113    }
114}
115
116/// One of the three metadata states a Check In balloon can advertise. The
117/// variant choice mirrors the query-string key the timestamp came from
118/// (`estimatedEndTime`, `triggerTime`, `sendDate`).
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum CheckInKind {
121    /// `estimatedEndTime`: Check In is scheduled and still pending.
122    Expected,
123    /// `triggerTime`: Check In window has passed without confirmation.
124    WasExpected,
125    /// `sendDate`: Check In was manually confirmed.
126    CheckedIn,
127}
128
129#[cfg(test)]
130mod tests {
131    use crate::{
132        message_types::{
133            app::{AppMessage, CheckInKind},
134            variants::BalloonProvider,
135        },
136        util::plist::parse_ns_keyed_archiver,
137    };
138    use plist::Value;
139    use std::fs::File;
140    use std::{collections::HashMap, env::current_dir};
141
142    fn check_in_msg(url: &str) -> AppMessage<'_> {
143        AppMessage {
144            image: None,
145            url: Some(url),
146            title: None,
147            subtitle: None,
148            caption: None,
149            subcaption: None,
150            trailing_caption: None,
151            trailing_subcaption: None,
152            app_name: Some("Check In"),
153            ldtext: None,
154        }
155    }
156
157    #[test]
158    fn check_in_kind_prefers_estimated_end_time() {
159        let balloon = check_in_msg(
160            "?estimatedEndTime=1697316869.688709&triggerTime=1697316869.688709&sendDate=1697316869.688709",
161        );
162        assert!(matches!(
163            balloon.check_in_kind(0),
164            Some((CheckInKind::Expected, _)),
165        ));
166    }
167
168    #[test]
169    fn check_in_kind_falls_back_to_trigger_time() {
170        let balloon = check_in_msg("?triggerTime=1697316869.688709&sendDate=1697316869.688709");
171        assert!(matches!(
172            balloon.check_in_kind(0),
173            Some((CheckInKind::WasExpected, _)),
174        ));
175    }
176
177    #[test]
178    fn check_in_kind_uses_send_date_when_only_option() {
179        let balloon = check_in_msg("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709");
180        assert!(matches!(
181            balloon.check_in_kind(0),
182            Some((CheckInKind::CheckedIn, _)),
183        ));
184    }
185
186    #[test]
187    fn check_in_kind_returns_none_for_unparsable_timestamp() {
188        let balloon = check_in_msg("?sendDate=not_a_number");
189        assert!(balloon.check_in_kind(0).is_none());
190    }
191
192    #[test]
193    fn check_in_kind_returns_none_for_overflowing_timestamp() {
194        // The nanosecond conversion (`seconds * 1_000_000_000`) overflows i64 for
195        // this value; it must degrade to None rather than panic (debug) or wrap to
196        // a bogus date (release).
197        let balloon = check_in_msg("?sendDate=99999999999");
198        assert!(balloon.check_in_kind(0).is_none());
199    }
200
201    #[test]
202    fn check_in_kind_returns_none_without_recognized_key() {
203        let balloon = check_in_msg("?messageType=1&interfaceVersion=1");
204        assert!(balloon.check_in_kind(0).is_none());
205    }
206
207    #[test]
208    fn test_parse_apple_pay_sent_265() {
209        let plist_path = current_dir()
210            .unwrap()
211            .as_path()
212            .join("test_data/app_message/Sent265.plist");
213        let plist_data = File::open(plist_path).unwrap();
214        let plist = Value::from_reader(plist_data).unwrap();
215        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
216
217        let balloon = AppMessage::from_map(&parsed).unwrap();
218        let expected = AppMessage {
219            image: None,
220            url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
221            title: None,
222            subtitle: None,
223            caption: Some("Apple\u{a0}Cash"),
224            subcaption: Some("$265\u{a0}Payment"),
225            trailing_caption: None,
226            trailing_subcaption: None,
227            app_name: Some("Apple\u{a0}Pay"),
228            ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
229        };
230
231        assert_eq!(balloon, expected);
232    }
233
234    #[test]
235    fn test_parse_apple_pay_recurring_1() {
236        let plist_path = current_dir()
237            .unwrap()
238            .as_path()
239            .join("test_data/app_message/ApplePayRecurring.plist");
240        let plist_data = File::open(plist_path).unwrap();
241        let plist = Value::from_reader(plist_data).unwrap();
242        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
243
244        let balloon = AppMessage::from_map(&parsed).unwrap();
245        let expected = AppMessage {
246            image: None,
247            url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
248            title: None,
249            subtitle: None,
250            caption: None,
251            subcaption: None,
252            trailing_caption: None,
253            trailing_subcaption: None,
254            app_name: Some("Apple\u{a0}Cash"),
255            ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
256        };
257
258        assert_eq!(balloon, expected);
259    }
260
261    #[test]
262    fn test_parse_opentable_invite() {
263        let plist_path = current_dir()
264            .unwrap()
265            .as_path()
266            .join("test_data/app_message/OpenTableInvited.plist");
267        let plist_data = File::open(plist_path).unwrap();
268        let plist = Value::from_reader(plist_data).unwrap();
269        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
270
271        let balloon = AppMessage::from_map(&parsed).unwrap();
272        let expected = AppMessage {
273            image: None,
274            url: Some(
275                "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
276            ),
277            title: Some("Rusty Grill - Boise"),
278            subtitle: Some("Reservation Confirmed"),
279            caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
280            subcaption: Some("You're invited! Tap to accept."),
281            trailing_caption: None,
282            trailing_subcaption: None,
283            app_name: Some("OpenTable"),
284            ldtext: None,
285        };
286
287        assert_eq!(balloon, expected);
288    }
289
290    #[test]
291    fn test_parse_slideshow() {
292        let plist_path = current_dir()
293            .unwrap()
294            .as_path()
295            .join("test_data/app_message/Slideshow.plist");
296        let plist_data = File::open(plist_path).unwrap();
297        let plist = Value::from_reader(plist_data).unwrap();
298        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
299
300        let balloon = AppMessage::from_map(&parsed).unwrap();
301        let expected = AppMessage {
302            image: None,
303            url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
304            title: None,
305            subtitle: None,
306            caption: Some("Home"),
307            subcaption: Some("37 Photos"),
308            trailing_caption: None,
309            trailing_subcaption: None,
310            app_name: Some("Photos"),
311            ldtext: Some("Home - 37 Photos"),
312        };
313
314        assert_eq!(balloon, expected);
315    }
316
317    #[test]
318    fn test_parse_game() {
319        let plist_path = current_dir()
320            .unwrap()
321            .as_path()
322            .join("test_data/app_message/Game.plist");
323        let plist_data = File::open(plist_path).unwrap();
324        let plist = Value::from_reader(plist_data).unwrap();
325        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
326
327        let balloon = AppMessage::from_map(&parsed).unwrap();
328        let expected = AppMessage {
329            image: None,
330            url: Some("data:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
331            title: None,
332            subtitle: None,
333            caption: Some("Your move."),
334            subcaption: None,
335            trailing_caption: None,
336            trailing_subcaption: None,
337            app_name: Some("GamePigeon"),
338            ldtext: Some("Dots & Boxes"),
339        };
340
341        assert_eq!(balloon, expected);
342    }
343
344    #[test]
345    fn test_parse_business() {
346        let plist_path = current_dir()
347            .unwrap()
348            .as_path()
349            .join("test_data/app_message/Business.plist");
350        let plist_data = File::open(plist_path).unwrap();
351        let plist = Value::from_reader(plist_data).unwrap();
352        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
353
354        let balloon = AppMessage::from_map(&parsed).unwrap();
355        let expected = AppMessage {
356            image: None,
357            url: Some(
358                "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
359            ),
360            title: None,
361            subtitle: None,
362            caption: Some("Yes, connect me with Goldman Sachs."),
363            subcaption: None,
364            trailing_caption: None,
365            trailing_subcaption: None,
366            app_name: Some("Business"),
367            ldtext: Some("Yes, connect me with Goldman Sachs."),
368        };
369
370        assert_eq!(balloon, expected);
371    }
372
373    #[test]
374    fn test_parse_business_query_string() {
375        let plist_path = current_dir()
376            .unwrap()
377            .as_path()
378            .join("test_data/app_message/Business.plist");
379        let plist_data = File::open(plist_path).unwrap();
380        let plist = Value::from_reader(plist_data).unwrap();
381        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
382
383        let balloon = AppMessage::from_map(&parsed).unwrap();
384        let mut expected = HashMap::new();
385        expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
386        expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
387
388        assert_eq!(balloon.parse_query_string(), expected);
389    }
390
391    #[test]
392    fn test_parse_check_in_timer() {
393        let plist_path = current_dir()
394            .unwrap()
395            .as_path()
396            .join("test_data/app_message/CheckinTimer.plist");
397        let plist_data = File::open(plist_path).unwrap();
398        let plist = Value::from_reader(plist_data).unwrap();
399        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
400
401        let balloon = AppMessage::from_map(&parsed).unwrap();
402
403        let expected = AppMessage {
404            image: None,
405            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
406            title: None,
407            subtitle: None,
408            caption: Some("Check In: Timer Started"),
409            subcaption: None,
410            trailing_caption: None,
411            trailing_subcaption: None,
412            app_name: Some("Check In"),
413            ldtext: Some("Check In: Timer Started"),
414        };
415
416        assert_eq!(balloon, expected);
417    }
418
419    #[test]
420    fn test_parse_check_in_timer_late() {
421        let plist_path = current_dir()
422            .unwrap()
423            .as_path()
424            .join("test_data/app_message/CheckinLate.plist");
425        let plist_data = File::open(plist_path).unwrap();
426        let plist = Value::from_reader(plist_data).unwrap();
427        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
428
429        let balloon = AppMessage::from_map(&parsed).unwrap();
430
431        let expected = AppMessage {
432            image: None,
433            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
434            title: None,
435            subtitle: None,
436            caption: Some("Check In: Has not checked in when expected, location shared"),
437            subcaption: None,
438            trailing_caption: None,
439            trailing_subcaption: None,
440            app_name: Some("Check In"),
441            ldtext: Some("Check In: Has not checked in when expected, location shared"),
442        };
443
444        assert_eq!(balloon, expected);
445    }
446
447    #[test]
448    fn test_parse_check_in_location() {
449        let plist_path = current_dir()
450            .unwrap()
451            .as_path()
452            .join("test_data/app_message/CheckinLocation.plist");
453        let plist_data = File::open(plist_path).unwrap();
454        let plist = Value::from_reader(plist_data).unwrap();
455        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
456
457        let balloon = AppMessage::from_map(&parsed).unwrap();
458
459        let expected = AppMessage {
460            image: None,
461            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
462            title: None,
463            subtitle: None,
464            caption: Some("Check In: Fake Location"),
465            subcaption: None,
466            trailing_caption: None,
467            trailing_subcaption: None,
468            app_name: Some("Check In"),
469            ldtext: Some("Check In: Fake Location"),
470        };
471
472        assert_eq!(balloon, expected);
473    }
474
475    #[test]
476    fn test_parse_check_in_query_string() {
477        let plist_path = current_dir()
478            .unwrap()
479            .as_path()
480            .join("test_data/app_message/CheckinTimer.plist");
481        let plist_data = File::open(plist_path).unwrap();
482        let plist = Value::from_reader(plist_data).unwrap();
483        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
484
485        let balloon = AppMessage::from_map(&parsed).unwrap();
486        let mut expected = HashMap::new();
487        expected.insert("messageType", "1");
488        expected.insert("interfaceVersion", "1");
489        expected.insert("sendDate", "1697316869.688709");
490
491        assert_eq!(balloon.parse_query_string(), expected);
492    }
493
494    #[test]
495    fn test_parse_find_my() {
496        let plist_path = current_dir()
497            .unwrap()
498            .as_path()
499            .join("test_data/app_message/FindMy.plist");
500        let plist_data = File::open(plist_path).unwrap();
501        let plist = Value::from_reader(plist_data).unwrap();
502        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
503
504        let balloon = AppMessage::from_map(&parsed).unwrap();
505        let expected = AppMessage {
506            image: None,
507            url: Some(
508                "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
509            ),
510            title: None,
511            subtitle: None,
512            caption: None,
513            subcaption: None,
514            trailing_caption: None,
515            trailing_subcaption: None,
516            app_name: Some("Find My"),
517            ldtext: Some("Started Sharing Location"),
518        };
519
520        assert_eq!(balloon, expected);
521    }
522}