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