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