imessage_database/message_types/
url.rs1use plist::Value;
8
9use crate::{
10 error::plist::PlistParseError,
11 message_types::{
12 app_store::AppStoreMessage,
13 collaboration::CollaborationMessage,
14 music::MusicMessage,
15 placemark::PlacemarkMessage,
16 variants::{BalloonProvider, HasUrl, URLOverride},
17 },
18 util::plist::{get_bool_from_dict, get_string_from_dict, get_string_from_nested_dict},
19};
20
21#[derive(Debug, PartialEq, Eq, Default)]
24pub struct URLMessage<'a> {
25 pub title: Option<&'a str>,
27 pub summary: Option<&'a str>,
29 pub url: Option<&'a str>,
31 pub original_url: Option<&'a str>,
33 pub item_type: Option<&'a str>,
35 pub images: Vec<&'a str>,
37 pub icons: Vec<&'a str>,
39 pub site_name: Option<&'a str>,
41 pub placeholder: bool,
43}
44
45impl<'a> BalloonProvider<'a> for URLMessage<'a> {
46 fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
47 let url_metadata = URLMessage::get_body(payload)?;
48 Ok(URLMessage {
49 title: get_string_from_dict(url_metadata, "title"),
50 summary: get_string_from_dict(url_metadata, "summary"),
51 url: get_string_from_nested_dict(url_metadata, "URL"),
52 original_url: get_string_from_nested_dict(url_metadata, "originalURL"),
53 item_type: get_string_from_dict(url_metadata, "itemType"),
54 images: URLMessage::get_array_from_nested_dict(url_metadata, "images"),
55 icons: URLMessage::get_array_from_nested_dict(url_metadata, "icons"),
56 site_name: get_string_from_dict(url_metadata, "siteName"),
57 placeholder: get_bool_from_dict(url_metadata, "richLinkIsPlaceholder").unwrap_or(false),
58 })
59 }
60}
61
62impl<'a> URLMessage<'a> {
63 pub fn get_url_message_override(
65 payload: &'a Value,
66 ) -> Result<URLOverride<'a>, PlistParseError> {
67 if let Ok(balloon) = CollaborationMessage::from_map(payload) {
68 return Ok(URLOverride::Collaboration(balloon));
69 }
70 if let Ok(balloon) = MusicMessage::from_map(payload) {
71 return Ok(URLOverride::AppleMusic(balloon));
72 }
73 if let Ok(balloon) = AppStoreMessage::from_map(payload) {
74 return Ok(URLOverride::AppStore(balloon));
75 }
76 if let Ok(balloon) = PlacemarkMessage::from_map(payload) {
77 return Ok(URLOverride::SharedPlacemark(balloon));
78 }
79 if let Ok(balloon) = URLMessage::from_map(payload) {
80 return Ok(URLOverride::Normal(balloon));
81 }
82 Err(PlistParseError::NoPayload)
83 }
84
85 fn get_body(payload: &'a Value) -> Result<&'a Value, PlistParseError> {
89 let root_dict = payload.as_dictionary().ok_or_else(|| {
90 PlistParseError::InvalidType("root".to_string(), "dictionary".to_string())
91 })?;
92
93 if let Some(meta) = root_dict.get("richLinkMetadata") {
94 return Ok(meta);
95 }
96 if let Some(meta) = root_dict.get("metadata") {
97 return Ok(meta);
98 }
99 Err(PlistParseError::NoPayload)
100 }
101
102 fn get_array_from_nested_dict(payload: &'a Value, key: &str) -> Vec<&'a str> {
118 let Some(items) = payload
119 .as_dictionary()
120 .and_then(|root| root.get(key))
121 .and_then(Value::as_dictionary)
122 .and_then(|nested| nested.get(key))
123 .and_then(Value::as_array)
124 else {
125 return Vec::new();
126 };
127
128 items
129 .iter()
130 .filter_map(|item| get_string_from_nested_dict(item, "URL"))
131 .collect()
132 }
133
134 #[must_use]
136 pub fn get_url(&self) -> Option<&str> {
137 <Self as HasUrl>::get_url(self)
138 }
139}
140
141impl HasUrl for URLMessage<'_> {
142 fn url(&self) -> Option<&str> {
143 self.url
144 }
145
146 fn original_url(&self) -> Option<&str> {
147 self.original_url
148 }
149}
150
151#[cfg(test)]
152mod url_tests {
153 use crate::{
154 message_types::{url::URLMessage, variants::BalloonProvider},
155 util::plist::parse_ns_keyed_archiver,
156 };
157 use plist::{Dictionary, Value};
158 use std::env::current_dir;
159 use std::fs::File;
160
161 fn nested_url(url: &str) -> Value {
162 let mut inner = Dictionary::new();
163 inner.insert("URL".to_string(), Value::String(url.to_string()));
164
165 let mut outer = Dictionary::new();
166 outer.insert("URL".to_string(), Value::Dictionary(inner));
167
168 Value::Dictionary(outer)
169 }
170
171 fn nested_array_payload(key: &str, items: Vec<Value>) -> Value {
172 let mut inner = Dictionary::new();
173 inner.insert(key.to_string(), Value::Array(items));
174
175 let mut outer = Dictionary::new();
176 outer.insert(key.to_string(), Value::Dictionary(inner));
177
178 Value::Dictionary(outer)
179 }
180
181 #[test]
182 fn test_parse_url_me() {
183 let plist_path = current_dir()
184 .unwrap()
185 .as_path()
186 .join("test_data/url_message/URL.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 = URLMessage::from_map(&parsed).unwrap();
192 let expected = URLMessage {
193 title: Some("Christopher Sardegna"),
194 summary: None,
195 url: Some("https://chrissardegna.com/"),
196 original_url: Some("https://chrissardegna.com"),
197 item_type: None,
198 images: vec![],
199 icons: vec!["https://chrissardegna.com/favicon.ico"],
200 site_name: None,
201 placeholder: false,
202 };
203
204 assert_eq!(balloon, expected);
205 }
206
207 #[test]
208 fn test_parse_url_me_metadata() {
209 let plist_path = current_dir()
210 .unwrap()
211 .as_path()
212 .join("test_data/url_message/MetadataURL.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 = URLMessage::from_map(&parsed).unwrap();
218 let expected = URLMessage {
219 title: Some("Christopher Sardegna"),
220 summary: Some("Sample page description"),
221 url: Some("https://chrissardegna.com"),
222 original_url: Some("https://chrissardegna.com"),
223 item_type: Some("article"),
224 images: vec!["https://chrissardegna.com/ddc-facebook-icon.png"],
225 icons: vec![
226 "https://chrissardegna.com/apple-touch-icon-180x180.png",
227 "https://chrissardegna.com/ddc-icon-32x32.png",
228 "https://chrissardegna.com/ddc-icon-16x16.png",
229 ],
230 site_name: Some("Christopher Sardegna"),
231 placeholder: false,
232 };
233
234 assert_eq!(balloon, expected);
235 }
236
237 #[test]
238 fn test_parse_url_twitter() {
239 let plist_path = current_dir()
240 .unwrap()
241 .as_path()
242 .join("test_data/url_message/Twitter.plist");
243 let plist_data = File::open(plist_path).unwrap();
244 let plist = Value::from_reader(plist_data).unwrap();
245 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
246
247 let balloon = URLMessage::from_map(&parsed).unwrap();
248 let expected = URLMessage {
249 title: Some("Christopher Sardegna on Twitter"),
250 summary: Some("“Hello Twitter, meet Bella”"),
251 url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
252 original_url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
253 item_type: Some("article"),
254 images: vec![
255 "https://pbs.twimg.com/media/EFGLfR2X4AE8ItK.jpg:large",
256 "https://pbs.twimg.com/media/EFGLfRmX4AMnwqW.jpg:large",
257 "https://pbs.twimg.com/media/EFGLfRlXYAYn9Ce.jpg:large",
258 ],
259 icons: vec![
260 "https://abs.twimg.com/icons/apple-touch-icon-192x192.png",
261 "https://abs.twimg.com/favicons/favicon.ico",
262 ],
263 site_name: Some("Twitter"),
264 placeholder: false,
265 };
266
267 assert_eq!(balloon, expected);
268 }
269
270 #[test]
271 fn test_parse_url_reminder() {
272 let plist_path = current_dir()
273 .unwrap()
274 .as_path()
275 .join("test_data/url_message/Reminder.plist");
276 let plist_data = File::open(plist_path).unwrap();
277 let plist = Value::from_reader(plist_data).unwrap();
278 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
279
280 let balloon = URLMessage::from_map(&parsed).unwrap();
281 let expected = URLMessage {
282 title: None,
283 summary: None,
284 url: None,
285 original_url: Some(
286 "https://www.icloud.com/reminders/ZmFrZXVybF9mb3JfcmVtaW5kZXI#TestList",
287 ),
288 item_type: None,
289 images: vec![],
290 icons: vec![],
291 site_name: None,
292 placeholder: false,
293 };
294
295 assert_eq!(balloon, expected);
296 }
297
298 #[test]
299 fn test_get_array_from_nested_dict_skips_malformed_entries() {
300 let payload = nested_array_payload(
301 "images",
302 vec![
303 nested_url("https://example.com/first.png"),
304 Value::Dictionary(Dictionary::new()),
305 nested_url(""),
306 nested_url("https://example.com/second.png"),
307 ],
308 );
309
310 assert_eq!(
311 URLMessage::get_array_from_nested_dict(&payload, "images"),
312 vec![
313 "https://example.com/first.png",
314 "https://example.com/second.png"
315 ]
316 );
317 }
318
319 #[test]
320 fn test_get_array_from_nested_dict_returns_empty_for_missing_list() {
321 let payload = Value::Dictionary(Dictionary::new());
322
323 assert!(URLMessage::get_array_from_nested_dict(&payload, "icons").is_empty());
324 }
325
326 #[test]
327 fn test_get_url() {
328 let expected = URLMessage {
329 title: Some("Christopher Sardegna"),
330 summary: None,
331 url: Some("https://chrissardegna.com/"),
332 original_url: Some("https://chrissardegna.com"),
333 item_type: None,
334 images: vec![],
335 icons: vec!["https://chrissardegna.com/favicon.ico"],
336 site_name: None,
337 placeholder: false,
338 };
339 assert_eq!(expected.get_url(), Some("https://chrissardegna.com/"));
340 }
341
342 #[test]
343 fn test_get_original_url() {
344 let expected = URLMessage {
345 title: Some("Christopher Sardegna"),
346 summary: None,
347 url: None,
348 original_url: Some("https://chrissardegna.com"),
349 item_type: None,
350 images: vec![],
351 icons: vec!["https://chrissardegna.com/favicon.ico"],
352 site_name: None,
353 placeholder: false,
354 };
355 assert_eq!(expected.get_url(), Some("https://chrissardegna.com"));
356 }
357
358 #[test]
359 fn test_get_no_url() {
360 let expected = URLMessage {
361 title: Some("Christopher Sardegna"),
362 summary: None,
363 url: None,
364 original_url: None,
365 item_type: None,
366 images: vec![],
367 icons: vec!["https://chrissardegna.com/favicon.ico"],
368 site_name: None,
369 placeholder: false,
370 };
371 assert_eq!(expected.get_url(), None);
372 }
373}
374
375#[cfg(test)]
376mod url_override_tests {
377 use crate::{
378 message_types::{url::URLMessage, variants::URLOverride},
379 util::plist::parse_ns_keyed_archiver,
380 };
381 use plist::Value;
382 use std::env::current_dir;
383 use std::fs::File;
384
385 #[test]
386 fn can_parse_normal() {
387 let plist_path = current_dir()
388 .unwrap()
389 .as_path()
390 .join("test_data/url_message/URL.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 = URLMessage::get_url_message_override(&parsed).unwrap();
396 assert!(matches!(balloon, URLOverride::Normal(_)));
397 }
398
399 #[test]
400 fn can_parse_music() {
401 let plist_path = current_dir()
402 .unwrap()
403 .as_path()
404 .join("test_data/music_message/AppleMusic.plist");
405 let plist_data = File::open(plist_path).unwrap();
406 let plist = Value::from_reader(plist_data).unwrap();
407 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
408
409 let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
410 assert!(matches!(balloon, URLOverride::AppleMusic(_)));
411 }
412
413 #[test]
414 fn can_parse_app_store() {
415 let plist_path = current_dir()
416 .unwrap()
417 .as_path()
418 .join("test_data/app_store/AppStoreLink.plist");
419 let plist_data = File::open(plist_path).unwrap();
420 let plist = Value::from_reader(plist_data).unwrap();
421 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
422
423 let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
424 assert!(matches!(balloon, URLOverride::AppStore(_)));
425 }
426
427 #[test]
428 fn can_parse_collaboration() {
429 let plist_path = current_dir()
430 .unwrap()
431 .as_path()
432 .join("test_data/collaboration_message/Freeform.plist");
433 let plist_data = File::open(plist_path).unwrap();
434 let plist = Value::from_reader(plist_data).unwrap();
435 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
436
437 let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
438 assert!(matches!(balloon, URLOverride::Collaboration(_)));
439 }
440
441 #[test]
442 fn can_parse_placemark() {
443 let plist_path = current_dir()
444 .unwrap()
445 .as_path()
446 .join("test_data/shared_placemark/SharedPlacemark.plist");
447 let plist_data = File::open(plist_path).unwrap();
448 let plist = Value::from_reader(plist_data).unwrap();
449 let parsed = parse_ns_keyed_archiver(&plist).unwrap();
450
451 let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
452 println!("{balloon:?}");
453 assert!(matches!(balloon, URLOverride::SharedPlacemark(_)));
454 }
455}