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            && url.starts_with('?')
74        {
75            let parts = url.strip_prefix('?').unwrap_or(url).split('&');
76            for part in parts {
77                let key_val_split: Vec<&str> = part.split('=').collect();
78                if key_val_split.len() == 2 {
79                    map.insert(key_val_split[0], key_val_split[1]);
80                }
81            }
82        }
83        map
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use crate::{
90        message_types::{app::AppMessage, variants::BalloonProvider},
91        util::plist::parse_ns_keyed_archiver,
92    };
93    use plist::Value;
94    use std::fs::File;
95    use std::{collections::HashMap, env::current_dir};
96
97    #[test]
98    fn test_parse_apple_pay_sent_265() {
99        let plist_path = current_dir()
100            .unwrap()
101            .as_path()
102            .join("test_data/app_message/Sent265.plist");
103        let plist_data = File::open(plist_path).unwrap();
104        let plist = Value::from_reader(plist_data).unwrap();
105        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
106
107        let balloon = AppMessage::from_map(&parsed).unwrap();
108        let expected = AppMessage {
109            image: None,
110            url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
111            title: None,
112            subtitle: None,
113            caption: Some("Apple\u{a0}Cash"),
114            subcaption: Some("$265\u{a0}Payment"),
115            trailing_caption: None,
116            trailing_subcaption: None,
117            app_name: Some("Apple\u{a0}Pay"),
118            ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
119        };
120
121        assert_eq!(balloon, expected);
122    }
123
124    #[test]
125    fn test_parse_apple_pay_recurring_1() {
126        let plist_path = current_dir()
127            .unwrap()
128            .as_path()
129            .join("test_data/app_message/ApplePayRecurring.plist");
130        let plist_data = File::open(plist_path).unwrap();
131        let plist = Value::from_reader(plist_data).unwrap();
132        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
133
134        let balloon = AppMessage::from_map(&parsed).unwrap();
135        let expected = AppMessage {
136            image: None,
137            url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
138            title: None,
139            subtitle: None,
140            caption: None,
141            subcaption: None,
142            trailing_caption: None,
143            trailing_subcaption: None,
144            app_name: Some("Apple\u{a0}Cash"),
145            ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
146        };
147
148        assert_eq!(balloon, expected);
149    }
150
151    #[test]
152    fn test_parse_opentable_invite() {
153        let plist_path = current_dir()
154            .unwrap()
155            .as_path()
156            .join("test_data/app_message/OpenTableInvited.plist");
157        let plist_data = File::open(plist_path).unwrap();
158        let plist = Value::from_reader(plist_data).unwrap();
159        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
160
161        let balloon = AppMessage::from_map(&parsed).unwrap();
162        let expected = AppMessage {
163            image: None,
164            url: Some(
165                "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
166            ),
167            title: Some("Rusty Grill - Boise"),
168            subtitle: Some("Reservation Confirmed"),
169            caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
170            subcaption: Some("You're invited! Tap to accept."),
171            trailing_caption: None,
172            trailing_subcaption: None,
173            app_name: Some("OpenTable"),
174            ldtext: None,
175        };
176
177        assert_eq!(balloon, expected);
178    }
179
180    #[test]
181    fn test_parse_slideshow() {
182        let plist_path = current_dir()
183            .unwrap()
184            .as_path()
185            .join("test_data/app_message/Slideshow.plist");
186        let plist_data = File::open(plist_path).unwrap();
187        let plist = Value::from_reader(plist_data).unwrap();
188        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
189
190        let balloon = AppMessage::from_map(&parsed).unwrap();
191        let expected = AppMessage {
192            image: None,
193            url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
194            title: None,
195            subtitle: None,
196            caption: Some("Home"),
197            subcaption: Some("37 Photos"),
198            trailing_caption: None,
199            trailing_subcaption: None,
200            app_name: Some("Photos"),
201            ldtext: Some("Home - 37 Photos"),
202        };
203
204        assert_eq!(balloon, expected);
205    }
206
207    #[test]
208    fn test_parse_game() {
209        let plist_path = current_dir()
210            .unwrap()
211            .as_path()
212            .join("test_data/app_message/Game.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:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
221            title: None,
222            subtitle: None,
223            caption: Some("Your move."),
224            subcaption: None,
225            trailing_caption: None,
226            trailing_subcaption: None,
227            app_name: Some("GamePigeon"),
228            ldtext: Some("Dots & Boxes"),
229        };
230
231        assert_eq!(balloon, expected);
232    }
233
234    #[test]
235    fn test_parse_business() {
236        let plist_path = current_dir()
237            .unwrap()
238            .as_path()
239            .join("test_data/app_message/Business.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(
248                "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
249            ),
250            title: None,
251            subtitle: None,
252            caption: Some("Yes, connect me with Goldman Sachs."),
253            subcaption: None,
254            trailing_caption: None,
255            trailing_subcaption: None,
256            app_name: Some("Business"),
257            ldtext: Some("Yes, connect me with Goldman Sachs."),
258        };
259
260        assert_eq!(balloon, expected);
261    }
262
263    #[test]
264    fn test_parse_business_query_string() {
265        let plist_path = current_dir()
266            .unwrap()
267            .as_path()
268            .join("test_data/app_message/Business.plist");
269        let plist_data = File::open(plist_path).unwrap();
270        let plist = Value::from_reader(plist_data).unwrap();
271        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
272
273        let balloon = AppMessage::from_map(&parsed).unwrap();
274        let mut expected = HashMap::new();
275        expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
276        expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
277
278        assert_eq!(balloon.parse_query_string(), expected);
279    }
280
281    #[test]
282    fn test_parse_check_in_timer() {
283        let plist_path = current_dir()
284            .unwrap()
285            .as_path()
286            .join("test_data/app_message/CheckinTimer.plist");
287        let plist_data = File::open(plist_path).unwrap();
288        let plist = Value::from_reader(plist_data).unwrap();
289        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
290
291        let balloon = AppMessage::from_map(&parsed).unwrap();
292
293        let expected = AppMessage {
294            image: None,
295            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
296            title: None,
297            subtitle: None,
298            caption: Some("Check In: Timer Started"),
299            subcaption: None,
300            trailing_caption: None,
301            trailing_subcaption: None,
302            app_name: Some("Check In"),
303            ldtext: Some("Check In: Timer Started"),
304        };
305
306        assert_eq!(balloon, expected);
307    }
308
309    #[test]
310    fn test_parse_check_in_timer_late() {
311        let plist_path = current_dir()
312            .unwrap()
313            .as_path()
314            .join("test_data/app_message/CheckinLate.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
321        let expected = AppMessage {
322            image: None,
323            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
324            title: None,
325            subtitle: None,
326            caption: Some("Check In: Has not checked in when expected, location shared"),
327            subcaption: None,
328            trailing_caption: None,
329            trailing_subcaption: None,
330            app_name: Some("Check In"),
331            ldtext: Some("Check In: Has not checked in when expected, location shared"),
332        };
333
334        assert_eq!(balloon, expected);
335    }
336
337    #[test]
338    fn test_parse_check_in_location() {
339        let plist_path = current_dir()
340            .unwrap()
341            .as_path()
342            .join("test_data/app_message/CheckinLocation.plist");
343        let plist_data = File::open(plist_path).unwrap();
344        let plist = Value::from_reader(plist_data).unwrap();
345        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
346
347        let balloon = AppMessage::from_map(&parsed).unwrap();
348
349        let expected = AppMessage {
350            image: None,
351            url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
352            title: None,
353            subtitle: None,
354            caption: Some("Check In: Fake Location"),
355            subcaption: None,
356            trailing_caption: None,
357            trailing_subcaption: None,
358            app_name: Some("Check In"),
359            ldtext: Some("Check In: Fake Location"),
360        };
361
362        assert_eq!(balloon, expected);
363    }
364
365    #[test]
366    fn test_parse_check_in_query_string() {
367        let plist_path = current_dir()
368            .unwrap()
369            .as_path()
370            .join("test_data/app_message/CheckinTimer.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("messageType", "1");
378        expected.insert("interfaceVersion", "1");
379        expected.insert("sendDate", "1697316869.688709");
380
381        assert_eq!(balloon.parse_query_string(), expected);
382    }
383
384    #[test]
385    fn test_parse_find_my() {
386        let plist_path = current_dir()
387            .unwrap()
388            .as_path()
389            .join("test_data/app_message/FindMy.plist");
390        let plist_data = File::open(plist_path).unwrap();
391        let plist = Value::from_reader(plist_data).unwrap();
392        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
393
394        let balloon = AppMessage::from_map(&parsed).unwrap();
395        let expected = AppMessage {
396            image: None,
397            url: Some(
398                "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
399            ),
400            title: None,
401            subtitle: None,
402            caption: None,
403            subcaption: None,
404            trailing_caption: None,
405            trailing_subcaption: None,
406            app_name: Some("Find My"),
407            ldtext: Some("Started Sharing Location"),
408        };
409
410        assert_eq!(balloon, expected);
411    }
412}