1use std::collections::HashMap;
3
4use serde::Deserialize;
5use serde_json::Value;
6
7#[derive(Debug, Clone, Deserialize)]
9pub struct RawEvent {
10 pub event: Option<String>,
12
13 #[serde(rename = "transactionId")]
15 pub transaction_id: Option<String>,
16
17 pub identifier: Option<String>,
19
20 pub guid: Option<String>,
22
23 pub data: Option<Value>,
25
26 pub error: Option<String>,
28
29 pub process: Option<String>,
31
32 #[serde(flatten)]
37 pub extra: HashMap<String, Value>,
38}
39
40impl RawEvent {
41 pub fn is_transaction_response(&self) -> bool {
43 self.transaction_id.is_some()
44 }
45
46 pub fn is_event(&self) -> bool {
48 self.event.is_some()
49 }
50
51 pub fn extract_data(&self) -> Option<Value> {
60 if self.data.is_some() {
61 return self.data.clone();
62 }
63 if !self.extra.is_empty() {
64 let map: serde_json::Map<String, Value> = self
65 .extra
66 .iter()
67 .map(|(k, v)| (k.clone(), v.clone()))
68 .collect();
69 Some(Value::Object(map))
70 } else {
71 None
72 }
73 }
74}
75
76pub mod event_types {
78 pub const PING: &str = "ping";
79 pub const READY: &str = "ready";
80 pub const STARTED_TYPING: &str = "started-typing";
81 pub const TYPING: &str = "typing";
82 pub const STOPPED_TYPING: &str = "stopped-typing";
83 pub const ALIASES_REMOVED: &str = "aliases-removed";
84 pub const FACETIME_CALL_STATUS_CHANGED: &str = "facetime-call-status-changed";
85 pub const NEW_FINDMY_LOCATION: &str = "new-findmy-location";
86}
87
88#[derive(Debug, Clone)]
90pub struct TypingEvent {
91 pub guid: String,
92 pub is_typing: bool,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum FaceTimeStatus {
98 Unknown = 0,
99 Answered = 1,
100 Outgoing = 3,
101 Incoming = 4,
102 Disconnected = 6,
103}
104
105impl FaceTimeStatus {
106 pub fn from_i64(val: i64) -> Self {
107 match val {
108 1 => Self::Answered,
109 3 => Self::Outgoing,
110 4 => Self::Incoming,
111 6 => Self::Disconnected,
112 _ => Self::Unknown,
113 }
114 }
115
116 pub fn as_str(&self) -> &'static str {
117 match self {
118 Self::Unknown => "unknown",
119 Self::Answered => "answered",
120 Self::Outgoing => "outgoing",
121 Self::Incoming => "incoming",
122 Self::Disconnected => "disconnected",
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct FaceTimeEvent {
130 pub call_uuid: String,
131 pub status: FaceTimeStatus,
132 pub status_id: i64,
133 pub address: String,
134 pub ended_error: Option<String>,
135 pub ended_reason: Option<String>,
136 pub image_url: Option<String>,
137 pub is_outgoing: bool,
138 pub is_audio: bool,
139 pub is_video: bool,
140}
141
142#[derive(Debug, Clone)]
144pub struct FindMyLocation {
145 pub handle: String,
146 pub coordinates: (f64, f64),
147 pub long_address: Option<String>,
148 pub short_address: Option<String>,
149 pub subtitle: Option<String>,
150 pub title: Option<String>,
151 pub last_updated: Option<i64>,
152 pub is_locating_in_progress: bool,
153 pub status: String,
154}
155
156pub fn parse_typing_event(raw: &RawEvent) -> Option<TypingEvent> {
158 let event_type = raw.event.as_deref()?;
159 let guid = raw.guid.as_deref()?;
160
161 if guid.contains(";+;") {
163 return None;
164 }
165
166 let is_typing = matches!(event_type, "started-typing" | "typing");
167 Some(TypingEvent {
168 guid: guid.to_string(),
169 is_typing,
170 })
171}
172
173pub fn parse_facetime_event(raw: &RawEvent) -> Option<FaceTimeEvent> {
175 let data = raw.data.as_ref()?;
176
177 let call_status = data.get("call_status")?.as_i64()?;
178 let call_uuid = data
179 .get("call_uuid")
180 .and_then(|v| v.as_str())
181 .unwrap_or("")
182 .to_string();
183 let address = data
184 .get("handle")
185 .and_then(|h| h.get("value"))
186 .and_then(|v| v.as_str())
187 .unwrap_or("")
188 .to_string();
189 let ended_error = data
190 .get("ended_error")
191 .and_then(|v| v.as_str())
192 .map(|s| s.to_string());
193 let ended_reason = data
194 .get("ended_reason")
195 .and_then(|v| v.as_str())
196 .map(|s| s.to_string());
197 let image_url = data
198 .get("image_url")
199 .and_then(|v| v.as_str())
200 .map(|s| s.to_string());
201 let is_outgoing = data
202 .get("is_outgoing")
203 .and_then(|v| v.as_bool())
204 .unwrap_or(false);
205 let is_audio = data
206 .get("is_sending_audio")
207 .and_then(|v| v.as_bool())
208 .unwrap_or(false);
209 let is_video = data
210 .get("is_sending_video")
211 .and_then(|v| v.as_bool())
212 .unwrap_or(false);
213
214 Some(FaceTimeEvent {
215 call_uuid,
216 status: FaceTimeStatus::from_i64(call_status),
217 status_id: call_status,
218 address,
219 ended_error,
220 ended_reason,
221 image_url,
222 is_outgoing,
223 is_audio,
224 is_video,
225 })
226}
227
228pub fn parse_findmy_locations(raw: &RawEvent) -> Vec<FindMyLocation> {
230 let Some(data) = raw.data.as_ref() else {
231 return vec![];
232 };
233 let Some(items) = data.as_array() else {
234 return vec![];
235 };
236
237 items
238 .iter()
239 .filter_map(|item| {
240 let handle = item.get("handle")?.as_str()?.to_string();
241 let coords = item.get("coordinates")?.as_array()?;
242 let lat = coords.first()?.as_f64()?;
243 let lon = coords.get(1)?.as_f64()?;
244
245 Some(FindMyLocation {
246 handle,
247 coordinates: (lat, lon),
248 long_address: item
249 .get("long_address")
250 .and_then(|v| v.as_str())
251 .map(|s| s.to_string()),
252 short_address: item
253 .get("short_address")
254 .and_then(|v| v.as_str())
255 .map(|s| s.to_string()),
256 subtitle: item
257 .get("subtitle")
258 .and_then(|v| v.as_str())
259 .map(|s| s.to_string()),
260 title: item
261 .get("title")
262 .and_then(|v| v.as_str())
263 .map(|s| s.to_string()),
264 last_updated: item.get("last_updated").and_then(|v| v.as_i64()),
265 is_locating_in_progress: item
266 .get("is_locating_in_progress")
267 .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0)))
268 .unwrap_or(false),
269 status: item
270 .get("status")
271 .and_then(|v| v.as_str())
272 .unwrap_or("unknown")
273 .to_string(),
274 })
275 })
276 .collect()
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use serde_json::json;
283
284 fn raw_event(overrides: impl FnOnce(&mut RawEvent)) -> RawEvent {
285 let mut e = RawEvent {
286 event: None,
287 transaction_id: None,
288 identifier: None,
289 guid: None,
290 data: None,
291 error: None,
292 process: None,
293 extra: HashMap::new(),
294 };
295 overrides(&mut e);
296 e
297 }
298
299 #[test]
300 fn parse_typing_dm() {
301 let raw = raw_event(|e| {
302 e.event = Some("started-typing".into());
303 e.guid = Some("iMessage;-;+15551234567".into());
304 });
305 let result = parse_typing_event(&raw).unwrap();
306 assert!(result.is_typing);
307 assert_eq!(result.guid, "iMessage;-;+15551234567");
308 }
309
310 #[test]
311 fn parse_typing_skips_group() {
312 let raw = raw_event(|e| {
313 e.event = Some("started-typing".into());
314 e.guid = Some("iMessage;+;chat123456".into());
315 });
316 assert!(parse_typing_event(&raw).is_none());
317 }
318
319 #[test]
320 fn parse_stopped_typing() {
321 let raw = raw_event(|e| {
322 e.event = Some("stopped-typing".into());
323 e.guid = Some("iMessage;-;+15551234567".into());
324 });
325 let result = parse_typing_event(&raw).unwrap();
326 assert!(!result.is_typing);
327 }
328
329 #[test]
330 fn parse_facetime_incoming() {
331 let raw = raw_event(|e| {
332 e.event = Some("facetime-call-status-changed".into());
333 e.data = Some(json!({
334 "call_status": 4,
335 "call_uuid": "abc-123",
336 "handle": { "value": "+15551234567" },
337 "is_outgoing": false,
338 "is_sending_audio": true,
339 "is_sending_video": false
340 }));
341 });
342 let facetime_event = parse_facetime_event(&raw).unwrap();
343 assert_eq!(facetime_event.status, FaceTimeStatus::Incoming);
344 assert_eq!(facetime_event.call_uuid, "abc-123");
345 assert_eq!(facetime_event.address, "+15551234567");
346 assert!(facetime_event.is_audio);
347 assert!(!facetime_event.is_video);
348 }
349
350 #[test]
351 fn parse_findmy_locations_multiple() {
352 let raw = raw_event(|e| {
353 e.event = Some("new-findmy-location".into());
354 e.data = Some(json!([
355 {
356 "handle": "+15551234567",
357 "coordinates": [37.7749, -122.4194],
358 "long_address": "San Francisco, CA",
359 "status": "live"
360 },
361 {
362 "handle": "user@icloud.com",
363 "coordinates": [40.7128, -74.0060],
364 "status": "legacy"
365 }
366 ]));
367 });
368 let locations = parse_findmy_locations(&raw);
369 assert_eq!(locations.len(), 2);
370 assert_eq!(locations[0].handle, "+15551234567");
371 assert_eq!(locations[0].status, "live");
372 assert_eq!(locations[1].handle, "user@icloud.com");
373 }
374
375 #[test]
376 fn facetime_status_from_i64() {
377 assert_eq!(FaceTimeStatus::from_i64(0), FaceTimeStatus::Unknown);
378 assert_eq!(FaceTimeStatus::from_i64(1), FaceTimeStatus::Answered);
379 assert_eq!(FaceTimeStatus::from_i64(4), FaceTimeStatus::Incoming);
380 assert_eq!(FaceTimeStatus::from_i64(6), FaceTimeStatus::Disconnected);
381 assert_eq!(FaceTimeStatus::from_i64(99), FaceTimeStatus::Unknown);
382 }
383
384 #[test]
385 fn extract_data_prefers_explicit_data_field() {
386 let raw = raw_event(|e| {
387 e.data = Some(json!({"links": []}));
388 e.extra.insert("stray".into(), json!("ignored"));
389 });
390 assert_eq!(raw.extract_data(), Some(json!({"links": []})));
391 }
392
393 #[test]
394 fn extract_data_falls_back_to_extra_fields() {
395 let raw = raw_event(|e| {
397 e.extra.insert(
398 "url".into(),
399 json!("https://facetime.apple.com/join#v=1&abc"),
400 );
401 });
402 let data = raw.extract_data().unwrap();
403 assert_eq!(data["url"], "https://facetime.apple.com/join#v=1&abc");
404 }
405
406 #[test]
407 fn extract_data_returns_none_when_empty() {
408 let raw = raw_event(|_| {});
409 assert!(raw.extract_data().is_none());
410 }
411
412 #[test]
413 fn extract_data_from_deserialized_json() {
414 let json_str = r#"{"transactionId":"abc","silenced":true}"#;
416 let raw: RawEvent = serde_json::from_str(json_str).unwrap();
417 assert!(raw.transaction_id.as_deref() == Some("abc"));
418 assert!(raw.data.is_none()); let data = raw.extract_data().unwrap();
420 assert_eq!(data["silenced"], true);
421 }
422}