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 * 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_without_recognized_key() {
195 let balloon = check_in_msg("?messageType=1&interfaceVersion=1");
196 assert!(balloon.check_in_kind(0).is_none());
197 }
198
199 #[test]
200 fn test_parse_apple_pay_sent_265() {
201 let plist_path = current_dir()
202 .unwrap()
203 .as_path()
204 .join("test_data/app_message/Sent265.plist");
205 let plist_data = File::open(plist_path).unwrap();
206 let plist = Value::from_reader(plist_data).unwrap();
207 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
208
209 let balloon = AppMessage::from_map(&parsed).unwrap();
210 let expected = AppMessage {
211 image: None,
212 url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
213 title: None,
214 subtitle: None,
215 caption: Some("Apple\u{a0}Cash"),
216 subcaption: Some("$265\u{a0}Payment"),
217 trailing_caption: None,
218 trailing_subcaption: None,
219 app_name: Some("Apple\u{a0}Pay"),
220 ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
221 };
222
223 assert_eq!(balloon, expected);
224 }
225
226 #[test]
227 fn test_parse_apple_pay_recurring_1() {
228 let plist_path = current_dir()
229 .unwrap()
230 .as_path()
231 .join("test_data/app_message/ApplePayRecurring.plist");
232 let plist_data = File::open(plist_path).unwrap();
233 let plist = Value::from_reader(plist_data).unwrap();
234 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
235
236 let balloon = AppMessage::from_map(&parsed).unwrap();
237 let expected = AppMessage {
238 image: None,
239 url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
240 title: None,
241 subtitle: None,
242 caption: None,
243 subcaption: None,
244 trailing_caption: None,
245 trailing_subcaption: None,
246 app_name: Some("Apple\u{a0}Cash"),
247 ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
248 };
249
250 assert_eq!(balloon, expected);
251 }
252
253 #[test]
254 fn test_parse_opentable_invite() {
255 let plist_path = current_dir()
256 .unwrap()
257 .as_path()
258 .join("test_data/app_message/OpenTableInvited.plist");
259 let plist_data = File::open(plist_path).unwrap();
260 let plist = Value::from_reader(plist_data).unwrap();
261 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
262
263 let balloon = AppMessage::from_map(&parsed).unwrap();
264 let expected = AppMessage {
265 image: None,
266 url: Some(
267 "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
268 ),
269 title: Some("Rusty Grill - Boise"),
270 subtitle: Some("Reservation Confirmed"),
271 caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
272 subcaption: Some("You're invited! Tap to accept."),
273 trailing_caption: None,
274 trailing_subcaption: None,
275 app_name: Some("OpenTable"),
276 ldtext: None,
277 };
278
279 assert_eq!(balloon, expected);
280 }
281
282 #[test]
283 fn test_parse_slideshow() {
284 let plist_path = current_dir()
285 .unwrap()
286 .as_path()
287 .join("test_data/app_message/Slideshow.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 let expected = AppMessage {
294 image: None,
295 url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
296 title: None,
297 subtitle: None,
298 caption: Some("Home"),
299 subcaption: Some("37 Photos"),
300 trailing_caption: None,
301 trailing_subcaption: None,
302 app_name: Some("Photos"),
303 ldtext: Some("Home - 37 Photos"),
304 };
305
306 assert_eq!(balloon, expected);
307 }
308
309 #[test]
310 fn test_parse_game() {
311 let plist_path = current_dir()
312 .unwrap()
313 .as_path()
314 .join("test_data/app_message/Game.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 let expected = AppMessage {
321 image: None,
322 url: Some("data:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
323 title: None,
324 subtitle: None,
325 caption: Some("Your move."),
326 subcaption: None,
327 trailing_caption: None,
328 trailing_subcaption: None,
329 app_name: Some("GamePigeon"),
330 ldtext: Some("Dots & Boxes"),
331 };
332
333 assert_eq!(balloon, expected);
334 }
335
336 #[test]
337 fn test_parse_business() {
338 let plist_path = current_dir()
339 .unwrap()
340 .as_path()
341 .join("test_data/app_message/Business.plist");
342 let plist_data = File::open(plist_path).unwrap();
343 let plist = Value::from_reader(plist_data).unwrap();
344 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
345
346 let balloon = AppMessage::from_map(&parsed).unwrap();
347 let expected = AppMessage {
348 image: None,
349 url: Some(
350 "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
351 ),
352 title: None,
353 subtitle: None,
354 caption: Some("Yes, connect me with Goldman Sachs."),
355 subcaption: None,
356 trailing_caption: None,
357 trailing_subcaption: None,
358 app_name: Some("Business"),
359 ldtext: Some("Yes, connect me with Goldman Sachs."),
360 };
361
362 assert_eq!(balloon, expected);
363 }
364
365 #[test]
366 fn test_parse_business_query_string() {
367 let plist_path = current_dir()
368 .unwrap()
369 .as_path()
370 .join("test_data/app_message/Business.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("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
378 expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
379
380 assert_eq!(balloon.parse_query_string(), expected);
381 }
382
383 #[test]
384 fn test_parse_check_in_timer() {
385 let plist_path = current_dir()
386 .unwrap()
387 .as_path()
388 .join("test_data/app_message/CheckinTimer.plist");
389 let plist_data = File::open(plist_path).unwrap();
390 let plist = Value::from_reader(plist_data).unwrap();
391 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
392
393 let balloon = AppMessage::from_map(&parsed).unwrap();
394
395 let expected = AppMessage {
396 image: None,
397 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
398 title: None,
399 subtitle: None,
400 caption: Some("Check In: Timer Started"),
401 subcaption: None,
402 trailing_caption: None,
403 trailing_subcaption: None,
404 app_name: Some("Check In"),
405 ldtext: Some("Check In: Timer Started"),
406 };
407
408 assert_eq!(balloon, expected);
409 }
410
411 #[test]
412 fn test_parse_check_in_timer_late() {
413 let plist_path = current_dir()
414 .unwrap()
415 .as_path()
416 .join("test_data/app_message/CheckinLate.plist");
417 let plist_data = File::open(plist_path).unwrap();
418 let plist = Value::from_reader(plist_data).unwrap();
419 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
420
421 let balloon = AppMessage::from_map(&parsed).unwrap();
422
423 let expected = AppMessage {
424 image: None,
425 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
426 title: None,
427 subtitle: None,
428 caption: Some("Check In: Has not checked in when expected, location shared"),
429 subcaption: None,
430 trailing_caption: None,
431 trailing_subcaption: None,
432 app_name: Some("Check In"),
433 ldtext: Some("Check In: Has not checked in when expected, location shared"),
434 };
435
436 assert_eq!(balloon, expected);
437 }
438
439 #[test]
440 fn test_parse_check_in_location() {
441 let plist_path = current_dir()
442 .unwrap()
443 .as_path()
444 .join("test_data/app_message/CheckinLocation.plist");
445 let plist_data = File::open(plist_path).unwrap();
446 let plist = Value::from_reader(plist_data).unwrap();
447 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
448
449 let balloon = AppMessage::from_map(&parsed).unwrap();
450
451 let expected = AppMessage {
452 image: None,
453 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
454 title: None,
455 subtitle: None,
456 caption: Some("Check In: Fake Location"),
457 subcaption: None,
458 trailing_caption: None,
459 trailing_subcaption: None,
460 app_name: Some("Check In"),
461 ldtext: Some("Check In: Fake Location"),
462 };
463
464 assert_eq!(balloon, expected);
465 }
466
467 #[test]
468 fn test_parse_check_in_query_string() {
469 let plist_path = current_dir()
470 .unwrap()
471 .as_path()
472 .join("test_data/app_message/CheckinTimer.plist");
473 let plist_data = File::open(plist_path).unwrap();
474 let plist = Value::from_reader(plist_data).unwrap();
475 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
476
477 let balloon = AppMessage::from_map(&parsed).unwrap();
478 let mut expected = HashMap::new();
479 expected.insert("messageType", "1");
480 expected.insert("interfaceVersion", "1");
481 expected.insert("sendDate", "1697316869.688709");
482
483 assert_eq!(balloon.parse_query_string(), expected);
484 }
485
486 #[test]
487 fn test_parse_find_my() {
488 let plist_path = current_dir()
489 .unwrap()
490 .as_path()
491 .join("test_data/app_message/FindMy.plist");
492 let plist_data = File::open(plist_path).unwrap();
493 let plist = Value::from_reader(plist_data).unwrap();
494 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
495
496 let balloon = AppMessage::from_map(&parsed).unwrap();
497 let expected = AppMessage {
498 image: None,
499 url: Some(
500 "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
501 ),
502 title: None,
503 subtitle: None,
504 caption: None,
505 subcaption: None,
506 trailing_caption: None,
507 trailing_subcaption: None,
508 app_name: Some("Find My"),
509 ldtext: Some("Started Sharing Location"),
510 };
511
512 assert_eq!(balloon, expected);
513 }
514}