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