From 982c0fe34f00e6405019a39b07ab29a57e501b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 13:47:38 +0200 Subject: [PATCH] Reduce duplicate InteractionC timeline events The interaction record's creation date normally trails its start date by milliseconds, so serializing both nearly doubled the timeline with duplicate entries. Only emit the creation date when it diverges from the start date by more than an hour, with explicit wording, since a record created long after its event indicates backfill by sync, restore or tampering. Per-contact aggregate dates from ZCONTACTS repeat on every interaction row of the same contact and carried that row's message text. Serialize them with contact-centric data strings instead, so timeline de-duplication collapses them into one first/last-seen event per contact. --- src/mvt/ios/modules/mixed/interactionc.py | 99 +++++++++++++++++- .../1f5a521220a3ad80ebfdc196978df8e7a2e49dee | Bin 24576 -> 24576 bytes tests/ios_backup/test_interactionc.py | 31 ++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/mvt/ios/modules/mixed/interactionc.py b/src/mvt/ios/modules/mixed/interactionc.py index 73d8002..4d21382 100644 --- a/src/mvt/ios/modules/mixed/interactionc.py +++ b/src/mvt/ios/modules/mixed/interactionc.py @@ -3,6 +3,7 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import datetime import logging import re import sqlite3 @@ -24,6 +25,49 @@ INTERACTIONC_BACKUP_IDS = [ INTERACTIONC_ROOT_PATHS = [ "private/var/mobile/Library/CoreDuet/People/interactionC.db", ] + +# The interaction record's creation date normally trails its start date by +# milliseconds: emitting it as a timeline event only duplicates the start +# date event. A large divergence, however, indicates the record was +# backfilled (sync, restore, or tampering) and is worth surfacing. +CREATION_DATE_DIVERGENCE_THRESHOLD = 3600.0 + +# Per-contact aggregate dates from ZCONTACTS are repeated on every +# interaction row of the same contact. They are serialized with a +# contact-centric data string so that timeline de-duplication collapses +# them into one event per contact. +CONTACT_EVENT_TEMPLATES = { + "contacts_creation_date": "Contact {party} first recorded in interactionC", + "first_incoming_sender_date": "First incoming interaction from {party}", + "last_incoming_sender_date": "Last incoming interaction from {party}", + "first_incoming_recipient_date": ( + "First incoming interaction where {party} was a recipient" + ), + "last_incoming_recipient_date": ( + "Last incoming interaction where {party} was a recipient" + ), + "first_outgoing_recipient_date": ( + "First outgoing interaction to {party}" + ), + "last_outgoing_recipient_date": ( + "Last outgoing interaction to {party}" + ), +} + + +def _parse_iso(timestamp) -> Optional[datetime.datetime]: + try: + return datetime.datetime.strptime( + timestamp, "%Y-%m-%d %H:%M:%S.%f" + ) + except (TypeError, ValueError): + return None + + +def _describe_delta(seconds: float) -> str: + if seconds >= 86400: + return f"{seconds / 86400:.0f} days" + return f"{seconds / 3600:.0f} hours" # Taken from APOLLO # https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt QUERIES = [ @@ -305,7 +349,7 @@ class InteractionC(IOSExtraction): records = [] processed = [] - for timestamp in self.timestamps: + for timestamp in ("start_date", "end_date"): # Check if the record has the current timestamp. if timestamp not in record or not record[timestamp]: continue @@ -324,8 +368,61 @@ class InteractionC(IOSExtraction): ) processed.append(record[timestamp]) + creation_event = self._serialize_creation_date(record, data) + if creation_event: + records.append(creation_event) + + # Contact-level aggregates describe the sender's contact record. + party = self._describe_party(record, "sender") + if party: + for field, template in CONTACT_EVENT_TEMPLATES.items(): + if not record.get(field): + continue + records.append( + { + "timestamp": record[field], + "module": self.__class__.__name__, + "event": field, + "data": template.format(party=party), + } + ) + return records + def _serialize_creation_date( + self, record: ModuleAtomicResult, data: str + ) -> Optional[dict]: + """Serialize the interaction record's creation date only when it + diverges from the start date enough to indicate the record was + backfilled.""" + creation = record.get("interactions_creation_date") + if not creation: + return None + + event = { + "timestamp": creation, + "module": self.__class__.__name__, + "event": "interactions_creation_date", + "data": data, + } + + start = _parse_iso(record.get("start_date")) + creation_parsed = _parse_iso(creation) + if not start or not creation_parsed: + # Without a start date the creation date is the only anchor. + return event + + delta = (creation_parsed - start).total_seconds() + if abs(delta) < CREATION_DATE_DIVERGENCE_THRESHOLD: + return None + + direction = "after" if delta > 0 else "before" + event["data"] = ( + f"Interaction record created {_describe_delta(abs(delta))} " + f"{direction} the event: {data}" + ) + return event + def _whatsapp_contact_maps(self) -> Tuple[dict, dict]: """Build LID and phone-digit lookup maps from the WhatsappContacts module results, when available.""" diff --git a/tests/artifacts/ios_backup/1f/1f5a521220a3ad80ebfdc196978df8e7a2e49dee b/tests/artifacts/ios_backup/1f/1f5a521220a3ad80ebfdc196978df8e7a2e49dee index 0bcf761caf9731b78afa3a185ca2e2cbe340335d..1d278325152b6c4a2d7bcc67d721b5a364b26f24 100644 GIT binary patch delta 65 zcmZoTz}RqraYL>=(+l6p`SQ~_DjE1s@UP;p+$^Y&%`c$OEWn_w$^Zc@3@no;>PvFy RWz;$76)t1gyh=aJ004GI5oQ1Y delta 77 zcmZoTz}RqraYL>=Q{l47`SQ~_8X5Rc@UP-;+$?BN%&)1=EWn_w%76r%@{<%?D-v@H Wa#FPoO$<$ez`)SJaPuntFarQXZWF=) diff --git a/tests/ios_backup/test_interactionc.py b/tests/ios_backup/test_interactionc.py index 69a9957..eec2656 100644 --- a/tests/ios_backup/test_interactionc.py +++ b/tests/ios_backup/test_interactionc.py @@ -56,6 +56,37 @@ class TestInteractionCModule: "Bob Example (+14155550101) to local user" in events ) + # The creation date is only serialized when it diverges from the + # start date; the SMS record was created 90 days after the event. + creation_events = [ + entry + for entry in m.timeline + if entry["event"] == "interactions_creation_date" + ] + assert len(creation_events) == 1 + assert creation_events[0]["timestamp"] == "2025-12-09 12:26:40.000000" + assert creation_events[0]["data"] == ( + "Interaction record created 90 days after the event: " + "[com.apple.MobileSMS] INCOMING from " + "Bob Example (+14155550101) to local user" + ) + + # Per-contact aggregate dates use contact-centric data strings. + first_seen = [ + entry + for entry in m.timeline + if entry["event"] == "first_incoming_sender_date" + ] + assert len(first_seen) == 1 + assert first_seen[0]["timestamp"] == "2025-09-03 13:46:40.000000" + assert first_seen[0]["data"] == ( + "First incoming interaction from Bob Example (+14155550101)" + ) + assert ( + "Last incoming interaction from Bob Example (+14155550101)" + in events + ) + def test_extraction_without_whatsapp_contacts(self): # Without the WhatsappContacts dependency the module still runs, and # unresolvable LIDs are shown as-is.