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 if url.starts_with('?') {
74 let parts = url.strip_prefix('?').unwrap_or(url).split('&');
75 for part in parts {
76 let key_val_split: Vec<&str> = part.split('=').collect();
77 if key_val_split.len() == 2 {
78 map.insert(key_val_split[0], key_val_split[1]);
79 }
80 }
81 }
82 }
83
84 map
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use crate::{
91 message_types::{app::AppMessage, variants::BalloonProvider},
92 util::plist::parse_ns_keyed_archiver,
93 };
94 use plist::Value;
95 use std::fs::File;
96 use std::{collections::HashMap, env::current_dir};
97
98 #[test]
99 fn test_parse_apple_pay_sent_265() {
100 let plist_path = current_dir()
101 .unwrap()
102 .as_path()
103 .join("test_data/app_message/Sent265.plist");
104 let plist_data = File::open(plist_path).unwrap();
105 let plist = Value::from_reader(plist_data).unwrap();
106 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
107
108 let balloon = AppMessage::from_map(&parsed).unwrap();
109 let expected = AppMessage {
110 image: None,
111 url: Some("data:application/vnd.apple.pkppm;base64,FAKE_BASE64_DATA="),
112 title: None,
113 subtitle: None,
114 caption: Some("Apple\u{a0}Cash"),
115 subcaption: Some("$265\u{a0}Payment"),
116 trailing_caption: None,
117 trailing_subcaption: None,
118 app_name: Some("Apple\u{a0}Pay"),
119 ldtext: Some("Sent $265 with Apple\u{a0}Pay."),
120 };
121
122 assert_eq!(balloon, expected);
123 }
124
125 #[test]
126 fn test_parse_apple_pay_recurring_1() {
127 let plist_path = current_dir()
128 .unwrap()
129 .as_path()
130 .join("test_data/app_message/ApplePayRecurring.plist");
131 let plist_data = File::open(plist_path).unwrap();
132 let plist = Value::from_reader(plist_data).unwrap();
133 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
134
135 let balloon = AppMessage::from_map(&parsed).unwrap();
136 let expected = AppMessage {
137 image: None,
138 url: Some("data:application/vnd.apple.pkppm;base64,FAKEDATA"),
139 title: None,
140 subtitle: None,
141 caption: None,
142 subcaption: None,
143 trailing_caption: None,
144 trailing_subcaption: None,
145 app_name: Some("Apple\u{a0}Cash"),
146 ldtext: Some("Sending you $1 weekly starting Nov 18, 2023"),
147 };
148
149 assert_eq!(balloon, expected);
150 }
151
152 #[test]
153 fn test_parse_opentable_invite() {
154 let plist_path = current_dir()
155 .unwrap()
156 .as_path()
157 .join("test_data/app_message/OpenTableInvited.plist");
158 let plist_data = File::open(plist_path).unwrap();
159 let plist = Value::from_reader(plist_data).unwrap();
160 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
161
162 let balloon = AppMessage::from_map(&parsed).unwrap();
163 let expected = AppMessage {
164 image: None,
165 url: Some(
166 "https://www.opentable.com/book/view?rid=0000000&confnumber=00000&invitationId=1234567890-abcd-def-ghij-4u5t1sv3ryc00l",
167 ),
168 title: Some("Rusty Grill - Boise"),
169 subtitle: Some("Reservation Confirmed"),
170 caption: Some("Table for 4 people\nSunday, October 17 at 7:45 PM"),
171 subcaption: Some("You're invited! Tap to accept."),
172 trailing_caption: None,
173 trailing_subcaption: None,
174 app_name: Some("OpenTable"),
175 ldtext: None,
176 };
177
178 assert_eq!(balloon, expected);
179 }
180
181 #[test]
182 fn test_parse_slideshow() {
183 let plist_path = current_dir()
184 .unwrap()
185 .as_path()
186 .join("test_data/app_message/Slideshow.plist");
187 let plist_data = File::open(plist_path).unwrap();
188 let plist = Value::from_reader(plist_data).unwrap();
189 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
190
191 let balloon = AppMessage::from_map(&parsed).unwrap();
192 let expected = AppMessage {
193 image: None,
194 url: Some("https://share.icloud.com/photos/1337h4x0r_jk#Home"),
195 title: None,
196 subtitle: None,
197 caption: Some("Home"),
198 subcaption: Some("37 Photos"),
199 trailing_caption: None,
200 trailing_subcaption: None,
201 app_name: Some("Photos"),
202 ldtext: Some("Home - 37 Photos"),
203 };
204
205 assert_eq!(balloon, expected);
206 }
207
208 #[test]
209 fn test_parse_game() {
210 let plist_path = current_dir()
211 .unwrap()
212 .as_path()
213 .join("test_data/app_message/Game.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:?ver=48&data=pr3t3ndth3r3154b10b0fd4t4h3re=3"),
222 title: None,
223 subtitle: None,
224 caption: Some("Your move."),
225 subcaption: None,
226 trailing_caption: None,
227 trailing_subcaption: None,
228 app_name: Some("GamePigeon"),
229 ldtext: Some("Dots & Boxes"),
230 };
231
232 assert_eq!(balloon, expected);
233 }
234
235 #[test]
236 fn test_parse_business() {
237 let plist_path = current_dir()
238 .unwrap()
239 .as_path()
240 .join("test_data/app_message/Business.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(
249 "?receivedMessage=33c309ab520bc2c76e99c493157ed578&replyMessage=6a991da615f2e75d4aa0de334e529024",
250 ),
251 title: None,
252 subtitle: None,
253 caption: Some("Yes, connect me with Goldman Sachs."),
254 subcaption: None,
255 trailing_caption: None,
256 trailing_subcaption: None,
257 app_name: Some("Business"),
258 ldtext: Some("Yes, connect me with Goldman Sachs."),
259 };
260
261 assert_eq!(balloon, expected);
262 }
263
264 #[test]
265 fn test_parse_business_query_string() {
266 let plist_path = current_dir()
267 .unwrap()
268 .as_path()
269 .join("test_data/app_message/Business.plist");
270 let plist_data = File::open(plist_path).unwrap();
271 let plist = Value::from_reader(plist_data).unwrap();
272 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
273
274 let balloon = AppMessage::from_map(&parsed).unwrap();
275 let mut expected = HashMap::new();
276 expected.insert("receivedMessage", "33c309ab520bc2c76e99c493157ed578");
277 expected.insert("replyMessage", "6a991da615f2e75d4aa0de334e529024");
278
279 assert_eq!(balloon.parse_query_string(), expected);
280 }
281
282 #[test]
283 fn test_parse_check_in_timer() {
284 let plist_path = current_dir()
285 .unwrap()
286 .as_path()
287 .join("test_data/app_message/CheckinTimer.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
294 let expected = AppMessage {
295 image: None,
296 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
297 title: None,
298 subtitle: None,
299 caption: Some("Check In: Timer Started"),
300 subcaption: None,
301 trailing_caption: None,
302 trailing_subcaption: None,
303 app_name: Some("Check In"),
304 ldtext: Some("Check In: Timer Started"),
305 };
306
307 assert_eq!(balloon, expected);
308 }
309
310 #[test]
311 fn test_parse_check_in_timer_late() {
312 let plist_path = current_dir()
313 .unwrap()
314 .as_path()
315 .join("test_data/app_message/CheckinLate.plist");
316 let plist_data = File::open(plist_path).unwrap();
317 let plist = Value::from_reader(plist_data).unwrap();
318 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
319
320 let balloon = AppMessage::from_map(&parsed).unwrap();
321
322 let expected = AppMessage {
323 image: None,
324 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
325 title: None,
326 subtitle: None,
327 caption: Some("Check In: Has not checked in when expected, location shared"),
328 subcaption: None,
329 trailing_caption: None,
330 trailing_subcaption: None,
331 app_name: Some("Check In"),
332 ldtext: Some("Check In: Has not checked in when expected, location shared"),
333 };
334
335 assert_eq!(balloon, expected);
336 }
337
338 #[test]
339 fn test_parse_check_in_location() {
340 let plist_path = current_dir()
341 .unwrap()
342 .as_path()
343 .join("test_data/app_message/CheckinLocation.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
350 let expected = AppMessage {
351 image: None,
352 url: Some("?messageType=1&interfaceVersion=1&sendDate=1697316869.688709"),
353 title: None,
354 subtitle: None,
355 caption: Some("Check In: Fake Location"),
356 subcaption: None,
357 trailing_caption: None,
358 trailing_subcaption: None,
359 app_name: Some("Check In"),
360 ldtext: Some("Check In: Fake Location"),
361 };
362
363 assert_eq!(balloon, expected);
364 }
365
366 #[test]
367 fn test_parse_check_in_query_string() {
368 let plist_path = current_dir()
369 .unwrap()
370 .as_path()
371 .join("test_data/app_message/CheckinTimer.plist");
372 let plist_data = File::open(plist_path).unwrap();
373 let plist = Value::from_reader(plist_data).unwrap();
374 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
375
376 let balloon = AppMessage::from_map(&parsed).unwrap();
377 let mut expected = HashMap::new();
378 expected.insert("messageType", "1");
379 expected.insert("interfaceVersion", "1");
380 expected.insert("sendDate", "1697316869.688709");
381
382 assert_eq!(balloon.parse_query_string(), expected);
383 }
384
385 #[test]
386 fn test_parse_find_my() {
387 let plist_path = current_dir()
388 .unwrap()
389 .as_path()
390 .join("test_data/app_message/FindMy.plist");
391 let plist_data = File::open(plist_path).unwrap();
392 let plist = Value::from_reader(plist_data).unwrap();
393 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
394
395 let balloon = AppMessage::from_map(&parsed).unwrap();
396 let expected = AppMessage {
397 image: None,
398 url: Some(
399 "?FindMyMessagePayloadVersionKey=v0&FindMyMessagePayloadZippedDataKey=FAKEDATA",
400 ),
401 title: None,
402 subtitle: None,
403 caption: None,
404 subcaption: None,
405 trailing_caption: None,
406 trailing_subcaption: None,
407 app_name: Some("Find My"),
408 ldtext: Some("Started Sharing Location"),
409 };
410
411 assert_eq!(balloon, expected);
412 }
413}