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 plist::Value;
9
10use crate::{
11    error::plist::PlistParseError,
12    message_types::variants::BalloonProvider,
13    util::plist::{get_string_from_dict, get_string_from_nested_dict},
14};
15
16/// This struct represents Apple's [`MSMessageTemplateLayout`](https://developer.apple.com/documentation/messages/msmessagetemplatelayout).
17#[derive(Debug, PartialEq, Eq)]
18pub struct AppMessage<'a> {
19    /// An image used to represent the message in the transcript
20    pub image: Option<&'a str>,
21    /// A URL pointing to a media file used to represent the message in the transcript
22    pub url: Option<&'a str>,
23    /// The title for the image or media file
24    pub title: Option<&'a str>,
25    /// The subtitle for the image or media file
26    pub subtitle: Option<&'a str>,
27    /// A left-aligned caption for the message bubble
28    pub caption: Option<&'a str>,
29    /// A left-aligned subcaption for the message bubble
30    pub subcaption: Option<&'a str>,
31    /// A right-aligned caption for the message bubble
32    pub trailing_caption: Option<&'a str>,
33    /// A right-aligned subcaption for the message bubble
34    pub trailing_subcaption: Option<&'a str>,
35    /// The name of the app that created this message
36    pub app_name: Option<&'a str>,
37    /// This property is set only for Apple system messages,
38    /// it represents the text that displays in the center of the bubble
39    pub ldtext: Option<&'a str>,
40}
41
42impl<'a> BalloonProvider<'a> for AppMessage<'a> {
43    fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
44        let user_info = payload
45            .as_dictionary()
46            .ok_or_else(|| {
47                PlistParseError::InvalidType("root".to_string(), "dictionary".to_string())
48            })?
49            .get("userInfo")
50            .ok_or_else(|| PlistParseError::MissingKey("userInfo".to_string()))?;
51        Ok(AppMessage {
52            image: get_string_from_dict(payload, "image"),
53            url: get_string_from_nested_dict(payload, "URL"),
54            title: get_string_from_dict(user_info, "image-title"),
55            subtitle: get_string_from_dict(user_info, "image-subtitle"),
56            caption: get_string_from_dict(user_info, "caption"),
57            subcaption: get_string_from_dict(user_info, "subcaption"),
58            trailing_caption: get_string_from_dict(user_info, "secondary-subcaption"),
59            trailing_subcaption: get_string_from_dict(user_info, "tertiary-subcaption"),
60            app_name: get_string_from_dict(payload, "an"),
61            ldtext: get_string_from_dict(payload, "ldtext"),
62        })
63    }
64}
65
66impl AppMessage<'_> {
67    /// Parse key/value pairs from the query string in the balloon's a URL
68    #[must_use]
69    pub fn parse_query_string(&self) -> HashMap<&str, &str> {
70        let mut map = HashMap::new();
71
72        if let Some(url) = self.url {
73            if url.starts_with('?') {
74                let parts = url.strip_prefix('?').unwrap_or(url).split('&');
75                for part in parts {
76                    let key_val_split: Vec<&str> = part.split('=').collect();
77                    if key_val_split.len() == 2 {
78                        map.insert(key_val_split[0], key_val_split[1]);
79                    }
80                }
81            }
82        }
83
84        map
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use crate::{
91        message_types::{app::AppMessage, variants::BalloonProvider},
92        util::plist::parse_ns_keyed_archiver,
93    };
94    use plist::Value;
95    use std::fs::File;
96    use std::{collections::HashMap, env::current_dir};
97
98    #[test]
99    fn test_parse_apple_pay_sent_265() {
100        let plist_path = current_dir()
101            .unwrap()
102            .as_path()
103            .join("test_data/app_message/Sent265.plist");
104        let plist_data = File::open(plist_path).unwrap();
105        let plist = Value::from_reader(plist_data).unwrap();
106        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
107
108        let balloon = AppMessage::from_map(&parsed).unwrap();
109        let expected = AppMessage {
110            image: None,
111            url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
112            title: None,
113            subtitle: None,
114            caption: Some("Apple\u{a0}Cash"),
115            subcaption: Some("$265\u{a0}Payment"),
116            trailing_caption: None,
117            trailing_subcaption: None,
118            app_name: Some("Apple\u{a0}Pay"),
119            ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
120        };
121
122        assert_eq!(balloon, expected);
123    }
124
125    #[test]
126    fn test_parse_apple_pay_recurring_1() {
127        let plist_path = current_dir()
128            .unwrap()
129            .as_path()
130            .join("test_data/app_message/ApplePayRecurring.plist");
131        let plist_data = File::open(plist_path).unwrap();
132        let plist = Value::from_reader(plist_data).unwrap();
133        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
134
135        let balloon = AppMessage::from_map(&parsed).unwrap();
136        let expected = AppMessage {
137            image: None,
138            url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
139            title: None,
140            subtitle: None,
141            caption: None,
142            subcaption: None,
143            trailing_caption: None,
144            trailing_subcaption: None,
145            app_name: Some("Apple\u{a0}Cash"),
146            ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
147        };
148
149        assert_eq!(balloon, expected);
150    }
151
152    #[test]
153    fn test_parse_opentable_invite() {
154        let plist_path = current_dir()
155            .unwrap()
156            .as_path()
157            .join("test_data/app_message/OpenTableInvited.plist");
158        let plist_data = File::open(plist_path).unwrap();
159        let plist = Value::from_reader(plist_data).unwrap();
160        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
161
162        let balloon = AppMessage::from_map(&parsed).unwrap();
163        let expected = AppMessage {
164            image: None,
165            url: Some(
166                "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
167            ),
168            title: Some("Rusty Grill - Boise"),
169            subtitle: Some("Reservation Confirmed"),
170            caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
171            subcaption: Some("You're invited! Tap to accept."),
172            trailing_caption: None,
173            trailing_subcaption: None,
174            app_name: Some("OpenTable"),
175            ldtext: None,
176        };
177
178        assert_eq!(balloon, expected);
179    }
180
181    #[test]
182    fn test_parse_slideshow() {
183        let plist_path = current_dir()
184            .unwrap()
185            .as_path()
186            .join("test_data/app_message/Slideshow.plist");
187        let plist_data = File::open(plist_path).unwrap();
188        let plist = Value::from_reader(plist_data).unwrap();
189        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
190
191        let balloon = AppMessage::from_map(&parsed).unwrap();
192        let expected = AppMessage {
193            image: None,
194            url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
195            title: None,
196            subtitle: None,
197            caption: Some("Home"),
198            subcaption: Some("37 Photos"),
199            trailing_caption: None,
200            trailing_subcaption: None,
201            app_name: Some("Photos"),
202            ldtext: Some("Home - 37 Photos"),
203        };
204
205        assert_eq!(balloon, expected);
206    }
207
208    #[test]
209    fn test_parse_game() {
210        let plist_path = current_dir()
211            .unwrap()
212            .as_path()
213            .join("test_data/app_message/Game.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:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
222            title: None,
223            subtitle: None,
224            caption: Some("Your move."),
225            subcaption: None,
226            trailing_caption: None,
227            trailing_subcaption: None,
228            app_name: Some("GamePigeon"),
229            ldtext: Some("Dots & Boxes"),
230        };
231
232        assert_eq!(balloon, expected);
233    }
234
235    #[test]
236    fn test_parse_business() {
237        let plist_path = current_dir()
238            .unwrap()
239            .as_path()
240            .join("test_data/app_message/Business.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(
249                "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
250            ),
251            title: None,
252            subtitle: None,
253            caption: Some("Yes, connect me with Goldman Sachs."),
254            subcaption: None,
255            trailing_caption: None,
256            trailing_subcaption: None,
257            app_name: Some("Business"),
258            ldtext: Some("Yes, connect me with Goldman Sachs."),
259        };
260
261        assert_eq!(balloon, expected);
262    }
263
264    #[test]
265    fn test_parse_business_query_string() {
266        let plist_path = current_dir()
267            .unwrap()
268            .as_path()
269            .join("test_data/app_message/Business.plist");
270        let plist_data = File::open(plist_path).unwrap();
271        let plist = Value::from_reader(plist_data).unwrap();
272        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
273
274        let balloon = AppMessage::from_map(&parsed).unwrap();
275        let mut expected = HashMap::new();
276        expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
277        expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
278
279        assert_eq!(balloon.parse_query_string(), expected);
280    }
281
282    #[test]
283    fn test_parse_check_in_timer() {
284        let plist_path = current_dir()
285            .unwrap()
286            .as_path()
287            .join("test_data/app_message/CheckinTimer.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
294        let expected = AppMessage {
295            image: None,
296            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
297            title: None,
298            subtitle: None,
299            caption: Some("Check In: Timer Started"),
300            subcaption: None,
301            trailing_caption: None,
302            trailing_subcaption: None,
303            app_name: Some("Check In"),
304            ldtext: Some("Check In: Timer Started"),
305        };
306
307        assert_eq!(balloon, expected);
308    }
309
310    #[test]
311    fn test_parse_check_in_timer_late() {
312        let plist_path = current_dir()
313            .unwrap()
314            .as_path()
315            .join("test_data/app_message/CheckinLate.plist");
316        let plist_data = File::open(plist_path).unwrap();
317        let plist = Value::from_reader(plist_data).unwrap();
318        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
319
320        let balloon = AppMessage::from_map(&parsed).unwrap();
321
322        let expected = AppMessage {
323            image: None,
324            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
325            title: None,
326            subtitle: None,
327            caption: Some("Check In: Has not checked in when expected, location shared"),
328            subcaption: None,
329            trailing_caption: None,
330            trailing_subcaption: None,
331            app_name: Some("Check In"),
332            ldtext: Some("Check In: Has not checked in when expected, location shared"),
333        };
334
335        assert_eq!(balloon, expected);
336    }
337
338    #[test]
339    fn test_parse_check_in_location() {
340        let plist_path = current_dir()
341            .unwrap()
342            .as_path()
343            .join("test_data/app_message/CheckinLocation.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
350        let expected = AppMessage {
351            image: None,
352            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
353            title: None,
354            subtitle: None,
355            caption: Some("Check In: Fake Location"),
356            subcaption: None,
357            trailing_caption: None,
358            trailing_subcaption: None,
359            app_name: Some("Check In"),
360            ldtext: Some("Check In: Fake Location"),
361        };
362
363        assert_eq!(balloon, expected);
364    }
365
366    #[test]
367    fn test_parse_check_in_query_string() {
368        let plist_path = current_dir()
369            .unwrap()
370            .as_path()
371            .join("test_data/app_message/CheckinTimer.plist");
372        let plist_data = File::open(plist_path).unwrap();
373        let plist = Value::from_reader(plist_data).unwrap();
374        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
375
376        let balloon = AppMessage::from_map(&parsed).unwrap();
377        let mut expected = HashMap::new();
378        expected.insert("messageType", "1");
379        expected.insert("interfaceVersion", "1");
380        expected.insert("sendDate", "1697316869.688709");
381
382        assert_eq!(balloon.parse_query_string(), expected);
383    }
384
385    #[test]
386    fn test_parse_find_my() {
387        let plist_path = current_dir()
388            .unwrap()
389            .as_path()
390            .join("test_data/app_message/FindMy.plist");
391        let plist_data = File::open(plist_path).unwrap();
392        let plist = Value::from_reader(plist_data).unwrap();
393        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
394
395        let balloon = AppMessage::from_map(&parsed).unwrap();
396        let expected = AppMessage {
397            image: None,
398            url: Some(
399                "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
400            ),
401            title: None,
402            subtitle: None,
403            caption: None,
404            subcaption: None,
405            trailing_caption: None,
406            trailing_subcaption: None,
407            app_name: Some("Find My"),
408            ldtext: Some("Started Sharing Location"),
409        };
410
411        assert_eq!(balloon, expected);
412    }
413}