1use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use plist::Value;
10
11use crate::{
12 error::plist::PlistParseError,
13 message_types::variants::BalloonProvider,
14 util::plist::{get_string_from_dict, get_string_from_nested_dict},
15};
16
17#[derive(Debug, PartialEq, Eq)]
19pub struct AppMessage<'a> {
20 pub image: Option<&'a str>,
22 pub url: Option<&'a str>,
24 pub title: Option<&'a str>,
26 pub subtitle: Option<&'a str>,
28 pub caption: Option<&'a str>,
30 pub subcaption: Option<&'a str>,
32 pub trailing_caption: Option<&'a str>,
34 pub trailing_subcaption: Option<&'a str>,
36 pub app_name: Option<&'a str>,
38 pub ldtext: Option<&'a str>,
41}
42
43impl<'a> BalloonProvider<'a> for AppMessage<'a> {
44 fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
45 let user_info = payload
46 .as_dictionary()
47 .ok_or_else(|| {
48 PlistParseError::InvalidType("root".to_string(), "dictionary".to_string())
49 })?
50 .get("userInfo")
51 .ok_or_else(|| PlistParseError::MissingKey("userInfo".to_string()))?;
52 Ok(AppMessage {
53 image: get_string_from_dict(payload, "image"),
54 url: get_string_from_nested_dict(payload, "URL"),
55 title: get_string_from_dict(user_info, "image-title"),
56 subtitle: get_string_from_dict(user_info, "image-subtitle"),
57 caption: get_string_from_dict(user_info, "caption"),
58 subcaption: get_string_from_dict(user_info, "subcaption"),
59 trailing_caption: get_string_from_dict(user_info, "secondary-subcaption"),
60 trailing_subcaption: get_string_from_dict(user_info, "tertiary-subcaption"),
61 app_name: get_string_from_dict(payload, "an"),
62 ldtext: get_string_from_dict(payload, "ldtext"),
63 })
64 }
65}
66
67impl AppMessage<'_> {
68 #[must_use]
70 pub fn parse_query_string(&self) -> HashMap<&str, &str> {
71 let mut map = HashMap::new();
72
73 if let Some(url) = self.url
74 && url.starts_with('?')
75 {
76 let parts = url.strip_prefix('?').unwrap_or(url).split('&');
77 for part in parts {
78 let key_val_split: Vec<&str> = part.split('=').collect();
79 if key_val_split.len() == 2 {
80 map.insert(key_val_split[0], key_val_split[1]);
81 }
82 }
83 }
84 map
85 }
86
87 #[must_use]
95 pub fn check_in_kind(&self) -> Option<(CheckInKind, DateTime<Utc>)> {
96 let metadata = self.parse_query_string();
97 let (kind, date_str) = if let Some(d) = metadata.get("estimatedEndTime") {
98 (CheckInKind::Expected, *d)
99 } else if let Some(d) = metadata.get("triggerTime") {
100 (CheckInKind::WasExpected, *d)
101 } else {
102 let d = metadata.get("sendDate")?;
103 (CheckInKind::CheckedIn, *d)
104 };
105 let secs = date_str.parse::<f64>().ok()? as i64;
106 let date_time = DateTime::from_timestamp(secs, 0)?;
107 Some((kind, date_time))
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CheckInKind {
116 Expected,
118 WasExpected,
120 CheckedIn,
122}
123
124#[cfg(test)]
125mod tests {
126 use crate::{
127 message_types::{
128 app::{AppMessage, CheckInKind},
129 variants::BalloonProvider,
130 },
131 util::plist::parse_ns_keyed_archiver,
132 };
133 use plist::Value;
134 use std::fs::File;
135 use std::{collections::HashMap, env::current_dir};
136
137 fn check_in_msg(url: &str) -> AppMessage<'_> {
138 AppMessage {
139 image: None,
140 url: Some(url),
141 title: None,
142 subtitle: None,
143 caption: None,
144 subcaption: None,
145 trailing_caption: None,
146 trailing_subcaption: None,
147 app_name: Some("Check In"),
148 ldtext: None,
149 }
150 }
151
152 #[test]
153 fn check_in_kind_prefers_estimated_end_time() {
154 let balloon = check_in_msg(
155 "?estimatedEndTime=1697316869.688709&triggerTime=1697316869.688709&sendDate=1697316869.688709",
156 );
157 assert!(matches!(
158 balloon.check_in_kind(),
159 Some((CheckInKind::Expected, _)),
160 ));
161 }
162
163 #[test]
164 fn check_in_kind_falls_back_to_trigger_time() {
165 let balloon = check_in_msg("?triggerTime=1697316869.688709&sendDate=1697316869.688709");
166 assert!(matches!(
167 balloon.check_in_kind(),
168 Some((CheckInKind::WasExpected, _)),
169 ));
170 }
171
172 #[test]
173 fn check_in_kind_uses_send_date_when_only_option() {
174 let balloon = check_in_msg("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709");
175 assert!(matches!(
176 balloon.check_in_kind(),
177 Some((CheckInKind::CheckedIn, _)),
178 ));
179 }
180
181 #[test]
182 fn check_in_kind_returns_none_for_unparsable_timestamp() {
183 let balloon = check_in_msg("?sendDate=not_a_number");
184 assert!(balloon.check_in_kind().is_none());
185 }
186
187 #[test]
188 fn check_in_kind_returns_none_for_overflowing_timestamp() {
189 let balloon = check_in_msg("?sendDate=1e30");
192 assert!(balloon.check_in_kind().is_none());
193 }
194
195 #[test]
196 fn check_in_kind_returns_none_without_recognized_key() {
197 let balloon = check_in_msg("?messageType=1&interfaceVersion=1");
198 assert!(balloon.check_in_kind().is_none());
199 }
200
201 #[test]
202 fn test_parse_apple_pay_sent_265() {
203 let plist_path = current_dir()
204 .unwrap()
205 .as_path()
206 .join("test_data/app_message/Sent265.plist");
207 let plist_data = File::open(plist_path).unwrap();
208 let plist = Value::from_reader(plist_data).unwrap();
209 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
210
211 let balloon = AppMessage::from_map(&parsed).unwrap();
212 let expected = AppMessage {
213 image: None,
214 url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
215 title: None,
216 subtitle: None,
217 caption: Some("Apple\u{a0}Cash"),
218 subcaption: Some("$265\u{a0}Payment"),
219 trailing_caption: None,
220 trailing_subcaption: None,
221 app_name: Some("Apple\u{a0}Pay"),
222 ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
223 };
224
225 assert_eq!(balloon, expected);
226 }
227
228 #[test]
229 fn test_parse_apple_pay_recurring_1() {
230 let plist_path = current_dir()
231 .unwrap()
232 .as_path()
233 .join("test_data/app_message/ApplePayRecurring.plist");
234 let plist_data = File::open(plist_path).unwrap();
235 let plist = Value::from_reader(plist_data).unwrap();
236 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
237
238 let balloon = AppMessage::from_map(&parsed).unwrap();
239 let expected = AppMessage {
240 image: None,
241 url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
242 title: None,
243 subtitle: None,
244 caption: None,
245 subcaption: None,
246 trailing_caption: None,
247 trailing_subcaption: None,
248 app_name: Some("Apple\u{a0}Cash"),
249 ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
250 };
251
252 assert_eq!(balloon, expected);
253 }
254
255 #[test]
256 fn test_parse_opentable_invite() {
257 let plist_path = current_dir()
258 .unwrap()
259 .as_path()
260 .join("test_data/app_message/OpenTableInvited.plist");
261 let plist_data = File::open(plist_path).unwrap();
262 let plist = Value::from_reader(plist_data).unwrap();
263 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
264
265 let balloon = AppMessage::from_map(&parsed).unwrap();
266 let expected = AppMessage {
267 image: None,
268 url: Some(
269 "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
270 ),
271 title: Some("Rusty Grill - Boise"),
272 subtitle: Some("Reservation Confirmed"),
273 caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
274 subcaption: Some("You're invited! Tap to accept."),
275 trailing_caption: None,
276 trailing_subcaption: None,
277 app_name: Some("OpenTable"),
278 ldtext: None,
279 };
280
281 assert_eq!(balloon, expected);
282 }
283
284 #[test]
285 fn test_parse_slideshow() {
286 let plist_path = current_dir()
287 .unwrap()
288 .as_path()
289 .join("test_data/app_message/Slideshow.plist");
290 let plist_data = File::open(plist_path).unwrap();
291 let plist = Value::from_reader(plist_data).unwrap();
292 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
293
294 let balloon = AppMessage::from_map(&parsed).unwrap();
295 let expected = AppMessage {
296 image: None,
297 url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
298 title: None,
299 subtitle: None,
300 caption: Some("Home"),
301 subcaption: Some("37 Photos"),
302 trailing_caption: None,
303 trailing_subcaption: None,
304 app_name: Some("Photos"),
305 ldtext: Some("Home - 37 Photos"),
306 };
307
308 assert_eq!(balloon, expected);
309 }
310
311 #[test]
312 fn test_parse_game() {
313 let plist_path = current_dir()
314 .unwrap()
315 .as_path()
316 .join("test_data/app_message/Game.plist");
317 let plist_data = File::open(plist_path).unwrap();
318 let plist = Value::from_reader(plist_data).unwrap();
319 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
320
321 let balloon = AppMessage::from_map(&parsed).unwrap();
322 let expected = AppMessage {
323 image: None,
324 url: Some("data:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
325 title: None,
326 subtitle: None,
327 caption: Some("Your move."),
328 subcaption: None,
329 trailing_caption: None,
330 trailing_subcaption: None,
331 app_name: Some("GamePigeon"),
332 ldtext: Some("Dots & Boxes"),
333 };
334
335 assert_eq!(balloon, expected);
336 }
337
338 #[test]
339 fn test_parse_business() {
340 let plist_path = current_dir()
341 .unwrap()
342 .as_path()
343 .join("test_data/app_message/Business.plist");
344 let plist_data = File::open(plist_path).unwrap();
345 let plist = Value::from_reader(plist_data).unwrap();
346 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
347
348 let balloon = AppMessage::from_map(&parsed).unwrap();
349 let expected = AppMessage {
350 image: None,
351 url: Some(
352 "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
353 ),
354 title: None,
355 subtitle: None,
356 caption: Some("Yes, connect me with Goldman Sachs."),
357 subcaption: None,
358 trailing_caption: None,
359 trailing_subcaption: None,
360 app_name: Some("Business"),
361 ldtext: Some("Yes, connect me with Goldman Sachs."),
362 };
363
364 assert_eq!(balloon, expected);
365 }
366
367 #[test]
368 fn test_parse_check_in() {
369 let cases = [
371 (
372 "timer started",
373 "CheckinTimer.plist",
374 "Check In: Timer Started",
375 ),
376 (
377 "late",
378 "CheckinLate.plist",
379 "Check In: Has not checked in when expected, location shared",
380 ),
381 (
382 "location",
383 "CheckinLocation.plist",
384 "Check In: Fake Location",
385 ),
386 ];
387 for (label, fixture, caption) in cases {
388 let plist_path = current_dir()
389 .unwrap()
390 .as_path()
391 .join("test_data/app_message")
392 .join(fixture);
393 let plist_data = File::open(plist_path).unwrap();
394 let plist = Value::from_reader(plist_data).unwrap();
395 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
396
397 let balloon = AppMessage::from_map(&parsed).unwrap();
398
399 let expected = AppMessage {
400 image: None,
401 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
402 title: None,
403 subtitle: None,
404 caption: Some(caption),
405 subcaption: None,
406 trailing_caption: None,
407 trailing_subcaption: None,
408 app_name: Some("Check In"),
409 ldtext: Some(caption),
410 };
411
412 assert_eq!(balloon, expected, "{label}");
413 }
414 }
415
416 #[test]
417 fn test_parse_query_string() {
418 let cases = [
419 (
420 "business",
421 "Business.plist",
422 HashMap::from([
423 ("receivedMessage", "33c309ab520bc2c76e99c493157ed578"),
424 ("replyMessage", "6a991da615f2e75d4aa0de334e529024"),
425 ]),
426 ),
427 (
428 "check in",
429 "CheckinTimer.plist",
430 HashMap::from([
431 ("messageType", "1"),
432 ("interfaceVersion", "1"),
433 ("sendDate", "1697316869.688709"),
434 ]),
435 ),
436 ];
437 for (label, fixture, expected) in cases {
438 let plist_path = current_dir()
439 .unwrap()
440 .as_path()
441 .join("test_data/app_message")
442 .join(fixture);
443 let plist_data = File::open(plist_path).unwrap();
444 let plist = Value::from_reader(plist_data).unwrap();
445 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
446
447 let balloon = AppMessage::from_map(&parsed).unwrap();
448
449 assert_eq!(balloon.parse_query_string(), expected, "{label}");
450 }
451 }
452
453 #[test]
454 fn test_parse_find_my() {
455 let plist_path = current_dir()
456 .unwrap()
457 .as_path()
458 .join("test_data/app_message/FindMy.plist");
459 let plist_data = File::open(plist_path).unwrap();
460 let plist = Value::from_reader(plist_data).unwrap();
461 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
462
463 let balloon = AppMessage::from_map(&parsed).unwrap();
464 let expected = AppMessage {
465 image: None,
466 url: Some(
467 "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
468 ),
469 title: None,
470 subtitle: None,
471 caption: None,
472 subcaption: None,
473 trailing_caption: None,
474 trailing_subcaption: None,
475 app_name: Some("Find My"),
476 ldtext: Some("Started Sharing Location"),
477 };
478
479 assert_eq!(balloon, expected);
480 }
481}