1use 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#[derive(Debug, PartialEq, Eq)]
18pub struct AppMessage<'a> {
19 pub image: Option<&'a str>,
21 pub url: Option<&'a str>,
23 pub title: Option<&'a str>,
25 pub subtitle: Option<&'a str>,
27 pub caption: Option<&'a str>,
29 pub subcaption: Option<&'a str>,
31 pub trailing_caption: Option<&'a str>,
33 pub trailing_subcaption: Option<&'a str>,
35 pub app_name: Option<&'a str>,
37 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 #[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}