diff --git a/ACKNOWLEDGEMENTS b/ACKNOWLEDGEMENTS index 89211b98e6..b3872de1ab 100644 --- a/ACKNOWLEDGEMENTS +++ b/ACKNOWLEDGEMENTS @@ -185,6 +185,11 @@ Copied with permission from [Digital Corpora](https://digitalcorpora.org/): * ios/com.apple.commcenter.data.plist * ios/com.apple.MobileBackup.plist * ios/healthdb_secure_iOS_13_4_1.sqlite +* ios/healthdb_secure_iOS_13_3_1.sqlite +* ios/healthdb_secure_iOS_13_4_1.sqlite +* ios/healthdb_secure_iOS_15.sqlite +* ios/healthdb_secure_iOS_16.sqlite +* ios/healthdb_secure_iOS_17.sqlite * ios/IMODb2.sqlite * ios/NoteStore.sqlite * viber_data diff --git a/plaso/data/formatters/ios.yaml b/plaso/data/formatters/ios.yaml index 0336c774bc..b76dd2be04 100644 --- a/plaso/data/formatters/ios.yaml +++ b/plaso/data/formatters/ios.yaml @@ -83,7 +83,7 @@ message: - 'Earned Date: {earned_date}' - 'Value: {value_in_canonical_unit} {value_canonical_unit}' - 'Creator Device ID: {creator_device}' -- 'Sync Provenance: {sync_provenance}' +- 'Synchronization Provenance: {synchronization_provenance}' short_message: - 'Achievement: {template_unique_name}' - 'Value: {value_in_canonical_unit}' @@ -107,6 +107,26 @@ short_source: 'SQLITE' source: 'iOS Health database' --- type: 'conditional' +data_type: 'ios:health:all_watch_sleep_ios17' +enumeration_helpers: +- input_attribute: 'sleep_state_code' + output_attribute: 'sleep_state_label' + default_value: 'UNKNOWN' + values: + 1: 'In Bed' + 2: 'Awake' + 3: 'REM' + 4: 'Core' + 5: 'Deep' +message: +- 'Sleep State: {sleep_state_label}' +- 'Duration: {sleep_state_hms}' +short_message: +- '{sleep_state_label} for {sleep_state_hms}' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' data_type: 'ios:health:headphone_audio_levels' message: - 'Sound Level: {decibels} dB' @@ -167,6 +187,22 @@ short_source: 'SQLITE' source: 'iOS Health database' --- type: 'conditional' +data_type: 'ios:health:source_devices_latest' +message: +- 'Device Name: {device_name}' +- 'Manufacturer: {manufacturer}' +- 'Model: {model}' +- 'Hardware Version: {hardware}' +- 'Software/Firmware: {software} / {firmware}' +- 'Local Identifier: {device_local_identifier}' +- 'Synchronization Provenance: {synchronization_provenance}' +- 'Synchronization Identity: {synchronization_identity}' +short_message: +- 'Source Device: {device_name} ({model})' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' data_type: 'ios:health:source_devices' message: - 'Device Name: {device_name}' @@ -175,8 +211,7 @@ message: - 'Hardware Version: {hardware}' - 'Software/Firmware: {software} / {firmware}' - 'Local Identifier: {device_local_identifier}' -- 'Sync Provenance: {sync_provenance}' -- 'Creation Date: {creation_date_str}' +- 'Synchronization Provenance: {synchronization_provenance}' short_message: - 'Source Device: {device_name} ({model})' short_source: 'SQLITE' @@ -194,6 +229,43 @@ short_source: 'SQLITE' source: 'iOS Health database' --- type: 'conditional' +data_type: 'ios:health:watch_by_sleep_period_latest' +message: +- 'Total Sleep Duration: {total_duration} minutes' +- 'Deep Sleep: {deep_duration_hms} ({deep_percent}%)' +- 'REM Sleep: {rem_duration_hms} ({rem_percent}%)' +- 'Core Sleep: {core_duration_hms} ({core_percent}%)' +- 'Awake: {awake_duration_hms} ({awake_percent}%)' +short_message: +- 'Sleep Period: {total_duration} min (Deep: {deep_percent}%, REM: {rem_percent}%)' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' +data_type: 'ios:health:watch_by_sleep_period' +message: +- 'Total Time in Bed: {time_in_bed_hms}' +- 'Actual Sleep Percent: {asleep_percent}%' +- 'In Bed Duration: {in_bed_duration_hms} ({in_bed_percent}%)' +- 'Device: {device_name}' +short_message: +- 'Sleep Period: {time_in_bed_hms} (Asleep: {asleep_percent}%)' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' +data_type: 'ios:health:watch_worn' +message: +- 'Duration Worn: {hours_worn} hours' +- 'Time Off After This Period: {hours_off_before_next} hours' +- 'Start Wearing: {start_time_str}' +- 'Last Worn Time: {last_worn_time_str}' +short_message: +- 'Worn for {hours_worn}h (Then off for {hours_off_before_next}h)' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' data_type: 'ios:health:weight' message: - 'Weight: {weight:.2f} kg' @@ -309,6 +381,123 @@ short_source: 'SQLITE' source: 'iOS Health database' --- type: 'conditional' +data_type: 'ios:health:workouts_ios16' +enumeration_helpers: +- input_attribute: 'activity_type' + output_attribute: 'activity_label' + default_value: 'OTHER' + values: + 1: "AMERICAN FOOTBALL" + 2: "ARCHERY" + 3: "AUSTRALIAN FOOTBALL" + 4: "BADMINTON" + 5: "BASEBALL" + 6: "BASKETBALL" + 7: "BOWLING" + 8: "BOXING" + 9: "CLIMBING" + 10: "CRICKET" + 11: "CROSS TRAINING" + 12: "CURLING" + 13: "CYCLING" + 16: "ELLIPTICAL" + 17: "EQUESTRIAN SPORTS" + 18: "FENCING" + 19: "FISHING" + 20: "FUNCTION STRENGTH TRAINING" + 21: "GOLF" + 22: "GYMNASTICS" + 23: "HANDBALL" + 24: "HIKING" + 25: "HOCKEY" + 26: "HUNTING" + 27: "LACROSS" + 28: "MARTIAL ARTS" + 29: "MIND AND BODY" + 31: "PADDLE SPORTS" + 32: "PLAY" + 33: "PREPARATION AND RECOVERY" + 34: "RACQUETBALL" + 35: "ROWING" + 36: "RUGBY" + 37: "RUNNING" + 38: "SAILING" + 39: "SKATING SPORTS" + 40: "SNOW SPORTS" + 41: "SOCCER" + 42: "SOFTBALL" + 43: "SQUASH" + 44: "STAIRSTEPPER" + 45: "SURFING SPORTS" + 46: "SWIMMING" + 47: "TABLE TENNIS" + 48: "TENNIS" + 49: "TRACK AND FIELD" + 50: "TRADITIONAL STRENGTH TRAINING" + 51: "VOLLEYBALL" + 52: "WALKING" + 53: "WATER FITNESS" + 54: "WATER POLO" + 55: "WATER SPORTS" + 56: "WRESTLING" + 57: "YOGA" + 58: "BARRE" + 59: "CORE TRAINING" + 60: "CROSS COUNTRY SKIING" + 61: "DOWNHILL SKIING" + 62: "FLEXIBILITY" + 63: "HIGH INTENSITY INTERVAL TRAINING (HIIT)" + 64: "JUMP ROPE" + 65: "KICKBOXING" + 66: "PILATES" + 67: "SNOWBOARDING" + 68: "STAIRS" + 69: "STEP TRAINING" + 70: "WHEELCHAIR WALK PACE" + 71: "WHEELCHAIR RUN PACE" + 72: "TAI CHI" + 73: "MIXED CARDIO" + 74: "HAND CYCLING" + 75: "DISC SPORTS" + 76: "FITNESS GAMING" + 77: "DANCE" + 78: "SOCIAL DANCE" + 79: "PICKLEBALL" + 80: "COOLDOWN" + 3000: "OTHER" +- input_attribute: 'goal_type' + output_attribute: 'goal_label' + default_value: 'Unknown' + values: + 0: "Open" + 1: "Distance in meters" + 2: "Time in seconds" + 3: "Kilocalories" +- input_attribute: 'location_type' + output_attribute: 'location_label' + default_value: 'Unknown' + values: + 1: 'Indoor' + 2: 'Outdoor' +message: +- 'Activity: {activity_label} ({location_label})' +- 'Workout Goal: {goal_label} ({goal})' +- 'Duration: {workout_duration}' +- 'Distance: {total_distance} km' +- 'Energy: {total_active_energy_kcal} kcal Active / {total_resting_energy_kcal} kcal Resting' +- 'Heart Rate: Avg {avg_heart_rate_bpm} BPM (Min: {min_heart_rate_bpm}, Max: {max_heart_rate_bpm})' +- 'Environment: {temperature_c} °C, {humidity_percent}% Humidity' +- 'Elevation: Min {min_ground_elevation_m}m, Max {max_ground_elevation_m}m' +- 'Location: Lat {latitude}, Lon {longitude}' +- 'Device: {hardware} (Source: {source})' +- 'Timezone: {timezone}' +- 'Added to Health: {added_timestamp_str}' +short_message: +- '{activity_label}: {total_distance} km in {workout_duration}' +short_source: 'SQLITE' +source: 'iOS Health database' +--- +type: 'conditional' data_type: 'ios:health:wrist_temperature' message: - 'Wrist temperature: {wrist_temperature} °C' diff --git a/plaso/parsers/sqlite_plugins/__init__.py b/plaso/parsers/sqlite_plugins/__init__.py index 78483a9dab..ef81a0c713 100644 --- a/plaso/parsers/sqlite_plugins/__init__.py +++ b/plaso/parsers/sqlite_plugins/__init__.py @@ -29,6 +29,12 @@ from plaso.parsers.sqlite_plugins import ios_accounts from plaso.parsers.sqlite_plugins import ios_datausage from plaso.parsers.sqlite_plugins import ios_health +from plaso.parsers.sqlite_plugins import ios_health_all_watch_sleep_latest +from plaso.parsers.sqlite_plugins import ios_health_source_devices_latest +from plaso.parsers.sqlite_plugins import ios_health_watch_by_sleep_period +from plaso.parsers.sqlite_plugins import ios_health_watch_by_sleep_period_latest +from plaso.parsers.sqlite_plugins import ios_health_watch_worn_data +from plaso.parsers.sqlite_plugins import ios_health_workouts_latest from plaso.parsers.sqlite_plugins import ios_imohdchat from plaso.parsers.sqlite_plugins import ios_instagram from plaso.parsers.sqlite_plugins import ios_kik diff --git a/plaso/parsers/sqlite_plugins/ios_health.py b/plaso/parsers/sqlite_plugins/ios_health.py index e2958bd254..be45ceec42 100644 --- a/plaso/parsers/sqlite_plugins/ios_health.py +++ b/plaso/parsers/sqlite_plugins/ios_health.py @@ -13,7 +13,7 @@ class IOSHealthAchievementsEventData(events.EventData): achievement. creator_device (int): identifier of the device that created the achievement. earned_date (str): Date the achievement was earned. - sync_provenance (int): Identifier for the sync provenance. + synchronization_provenance (int): synchronization provenance. template_unique_name (str): Unique name of the achievement template. value_canonical_unit (str): Unit of the value (e.g., "count"). value_in_canonical_unit (float): Value of the achievement in canonical units. @@ -27,7 +27,7 @@ def __init__(self): self.creation_time = None self.creator_device = None self.earned_date = None - self.sync_provenance = None + self.synchronization_provenance = None self.template_unique_name = None self.value_canonical_unit = None self.value_in_canonical_unit = None @@ -37,6 +37,7 @@ class IOSHealthAllWatchSleepEventData(events.EventData): """iOS Health all watch sleep event data. Attributes: + duration (float): duration in seconds. end_time (dfdatetime.DateTimeValues): date and time the sleep ended. sleep_state_code (int): sleep state code. start_time (dfdatetime.DateTimeValues): date and time the sleep started. @@ -47,6 +48,7 @@ class IOSHealthAllWatchSleepEventData(events.EventData): def __init__(self): """Initializes event data.""" super().__init__(data_type=self.DATA_TYPE) + self.duration = None self.end_time = None self.sleep_state_code = None self.start_time = None @@ -180,7 +182,8 @@ class IOSHealthSourceDevicesEventData(events.EventData): manufacturer (str): manufacturer of the device. model (str): model of the device. software (str): software version of the device. - sync_provenance (int): sync provenance information. + synchronization_identity (int): synchronization identifier. + synchronization_provenance (int): synchronization provenance. """ DATA_TYPE = "ios:health:source_devices" @@ -196,7 +199,8 @@ def __init__(self): self.manufacturer = None self.model = None self.software = None - self.sync_provenance = None + self.synchronization_identity = None + self.synchronization_provenance = None class IOSHealthStepsEventData(events.EventData): @@ -896,7 +900,7 @@ def _ParseAchievementRow(self, parser_mediator, query, row, **unused_kwargs): query_hash, row, "value_canonical_unit" ) event_data.creator_device = self._GetRowValue(query_hash, row, "creator_device") - event_data.sync_provenance = self._GetRowValue( + event_data.synchronization_provenance = self._GetRowValue( query_hash, row, "sync_provenance" ) parser_mediator.ProduceEventData(event_data) @@ -919,6 +923,16 @@ def _ParseAllWatchSleepSample(self, parser_mediator, query_hash, row): event_data.sleep_state_code = self._GetRowValue( query_hash, row, "category_value" ) + if ( + event_data.end_time + and event_data.start_time + and event_data.end_time.timestamp is not None + and event_data.start_time.timestamp is not None + ): + event_data.duration = ( + event_data.end_time.timestamp - event_data.start_time.timestamp + ) + parser_mediator.ProduceEventData(event_data) def _ParseHeadphoneAudioLevelSample( @@ -1133,7 +1147,7 @@ def _ParseSourceDevicesRow(self, parser_mediator, query, row, **unused_kwargs): event_data.manufacturer = self._GetRowValue(query_hash, row, "manufacturer") event_data.model = self._GetRowValue(query_hash, row, "model") event_data.software = self._GetRowValue(query_hash, row, "software") - event_data.sync_provenance = self._GetRowValue( + event_data.synchronization_provenance = self._GetRowValue( query_hash, row, "sync_provenance" ) parser_mediator.ProduceEventData(event_data) @@ -1159,7 +1173,6 @@ def _ParseStepsSample(self, parser_mediator, query_hash, row): event_data.start_time = self._GetCocoaTimeRowValue( query_hash, row, "start_date" ) - if ( event_data.end_time and event_data.start_time diff --git a/plaso/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py b/plaso/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py new file mode 100644 index 0000000000..2d72d1ab3d --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py @@ -0,0 +1,93 @@ +"""SQLite parser plugin for iOS Health - All Watch Sleep Data (iOS 17).""" + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthAllWatchSleepLatestEventData(events.EventData): + """iOS Health - All Watch Sleep (stages) event data. + + Attributes: + duration (float): duration in seconds. + end_time (dfdatetime.DateTimeValues): date and time the sleep ended. + sleep_state_code (int): sleep state code (stages 2-5). + start_time (dfdatetime.DateTimeValues): date and time the sleep started. + """ + + DATA_TYPE = "ios:health:all_watch_sleep_ios17" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.duration = None + self.end_time = None + self.sleep_state_code = None + self.start_time = None + + +class IOSHealthAllWatchSleepLatestPlugin(interface.SQLitePlugin): + """SQLite parser plugin for iOS Health Sleep Stages (iOS 17+).""" + + NAME = "ios_health_all_watch_sleep_ios17" + DATA_FORMAT = "iOS Health Sleep Stages from healthdb_secure.sqlite (iOS 17+)" + + REQUIRED_STRUCTURE = { + "samples": frozenset(["data_id", "start_date", "end_date"]), + "category_samples": frozenset(["data_id", "value"]), + } + + QUERIES = [ + ( + ( + "SELECT samples.start_date AS start_date, " + "samples.end_date AS end_date, " + "category_samples.value AS category_value " + "FROM samples " + "JOIN category_samples ON samples.data_id = category_samples.data_id" + ), + "ParseSleepRow", + ) + ] + + def ParseSleepRow(self, parser_mediator, query, row, **unused_kwargs): + """Parses a sleep data row. + + Args: + parser_mediator (ParserMediator): mediates interactions. + query (str): query that created the row. + row (sqlite3.Row): row. + """ + query_hash = hash(query) + + raw_code = self._GetRowValue(query_hash, row, "category_value") + try: + code = int(raw_code) if raw_code is not None else None + except (TypeError, ValueError): + code = None + + # TODO: what do these category values represent? + if code not in (2, 3, 4, 5): + return + + event_data = IOSHealthAllWatchSleepLatestEventData() + event_data.end_time = self._GetCocoaTimeRowValue(query_hash, row, "end_date") + event_data.sleep_state_code = code + event_data.start_time = self._GetCocoaTimeRowValue( + query_hash, row, "start_date" + ) + + if ( + event_data.end_time + and event_data.start_time + and event_data.end_time.timestamp is not None + and event_data.start_time.timestamp is not None + ): + event_data.duration = ( + event_data.end_time.timestamp - event_data.start_time.timestamp + ) + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthAllWatchSleepLatestPlugin) diff --git a/plaso/parsers/sqlite_plugins/ios_health_source_devices_latest.py b/plaso/parsers/sqlite_plugins/ios_health_source_devices_latest.py new file mode 100644 index 0000000000..660eea56da --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_source_devices_latest.py @@ -0,0 +1,119 @@ +"""SQLite parser plugin for iOS Health Source Devices Latest 16-17 data.""" + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthSourceDevicesLatestEventData(events.EventData): + """iOS Health Source Devices Latest event data. + + Attributes: + added_time (dfdatetime.DateTimeValues): date and time the device was added to the + database. + device_name (str): name of the device. + firmware (str): firmware version of the device. + hardware (str): hardware version of the device. + local_identifier (str): local identifier. + manufacturer (str): manufacturer of the device. + model (str): model of the device. + software (str): software version of the device. + sync_identity (str): sync identity information. + sync_provenance (str): sync provenance information. + """ + + DATA_TYPE = "ios:health:source_devices_latest" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.added_time = None + self.device_name = None + self.firmware = None + self.hardware = None + self.local_identifier = None + self.manufacturer = None + self.model = None + self.software = None + self.sync_identity = None + self.sync_provenance = None + + +class IOSHealthSourceDevicesLatestPlugin(interface.SQLitePlugin): + """SQLite parser plugin for iOS Health Source Devices Latest data.""" + + NAME = "ios_health_source_devices_latest" + DATA_FORMAT = "iOS Health Source Devices Data from healthdb.sqlite" + + REQUIRED_STRUCTURE = { + "source_devices": frozenset( + [ + "name", + "manufacturer", + "model", + "hardware", + "firmware", + "software", + "localIdentifier", + "sync_provenance", + "sync_identity", + "creation_date", + ] + ) + } + + QUERIES = [ + ( + ( + "SELECT creation_date, name AS device_name, manufacturer, model, " + "hardware, firmware, software, localIdentifier, sync_provenance, " + 'sync_identity FROM source_devices WHERE name NOT LIKE "__NONE__" ' + 'AND localIdentifier NOT LIKE "__NONE__"' + ), + "ParseSourceDevicesRow", + ) + ] + + SCHEMAS = { + "source_devices": ( + "CREATE TABLE source_devices (data_id INTEGER PRIMARY KEY, name " + "TEXT, manufacturer TEXT, model TEXT, hardware TEXT, firmware TEXT, " + "software TEXT, localIdentifier TEXT, sync_provenance TEXT, " + "sync_identity TEXT, creation_date INTEGER)" + ) + } + + REQUIRE_SCHEMA_MATCH = False + + def ParseSourceDevicesRow(self, parser_mediator, query, row, **unused_kwargs): + """Parses a single source device row. + + Args: + parser_mediator (ParserMediator): mediates interactions. + query (str): query that created the row. + row (sqlite3.Row): row. + """ + query_hash = hash(query) + event_data = IOSHealthSourceDevicesLatestEventData() + + event_data.added_time = self._GetCocoaTimeRowValue( + query_hash, row, "creation_date" + ) + event_data.device_name = self._GetRowValue(query_hash, row, "device_name") + event_data.manufacturer = self._GetRowValue(query_hash, row, "manufacturer") + event_data.model = self._GetRowValue(query_hash, row, "model") + event_data.hardware = self._GetRowValue(query_hash, row, "hardware") or None + event_data.firmware = self._GetRowValue(query_hash, row, "firmware") or None + event_data.software = self._GetRowValue(query_hash, row, "software") or None + event_data.local_identifier = ( + self._GetRowValue(query_hash, row, "localIdentifier") or None + ) + event_data.sync_provenance = self._GetRowValue( + query_hash, row, "sync_provenance" + ) + event_data.sync_identity = self._GetRowValue(query_hash, row, "sync_identity") + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthSourceDevicesLatestPlugin) diff --git a/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py b/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py new file mode 100644 index 0000000000..0d625760e4 --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py @@ -0,0 +1,147 @@ +"""SQLite parser plugin for iOS Health - Watch By Sleep Period Data.""" + +from dfdatetime import cocoa_time as dfdatetime_cocoa_time + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthWatchBySleepPeriodEventData(events.EventData): + """iOS Health - Watch By Sleep Period event data. + + Attributes: + asleep_percent (float): percentage of time spent asleep. + device_name (str): name of the recording device. + end_time (dfdatetime.DateTimeValues): date and time the sleep ended. + in_bed_duration (float): time spent in bed. + in_bed_percent (float): percentage of time spent in bed. + start_time (dfdatetime.DateTimeValues): date and time the sleep started. + total_duration (float): total duration. + """ + + DATA_TYPE = "ios:health:watch_by_sleep_period" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.asleep_percent = None + self.device_name = None + self.end_time = None + self.in_bed_duration = None + self.in_bed_percent = None + self.start_time = None + self.total_duration = None + + +class IOSHealthWatchBySleepPeriodPlugin(interface.SQLitePlugin): + """SQLite parser plugin for iOS Health Watch By Sleep Period Data.""" + + NAME = "ios_health_watch_by_sleep_period" + DATA_FORMAT = ( + "iOS Health Watch By Sleep Period Data from healthdb_secure.sqlite " + "(iOS 15-17)" + ) + + REQUIRED_STRUCTURE = { + "samples": frozenset(["data_id", "start_date", "end_date", "data_type"]), + "category_samples": frozenset(["data_id", "value"]), + "data_provenances": frozenset(["origin_product_type"]), + "objects": frozenset(["data_id", "provenance"]), + } + + QUERIES = [ + ( + ( + "WITH lagged_samples AS (SELECT s.data_id, s.start_date, s.end_date, " + "(s.end_date - s.start_date) / 60 AS duration_minutes, s.data_type, " + "cs.value, dp.origin_product_type, LAG(s.data_id) OVER (ORDER BY " + 's.data_id) AS prev_data_id, CASE cs.value WHEN 0 THEN "IN BED" ' + 'WHEN 1 THEN "ASLEEP" END AS sleep_value FROM samples s LEFT JOIN ' + "category_samples cs ON s.data_id = cs.data_id LEFT JOIN objects o " + "ON s.data_id = o.data_id LEFT JOIN data_provenances dp ON " + "o.provenance = dp.rowid WHERE s.data_type = 63 AND cs.value IN (0, 1) " + 'AND dp.origin_product_type LIKE "%Watch%"), grouped_samples AS (' + "SELECT *, CASE WHEN prev_data_id IS NULL OR data_id - " + "prev_data_id > 1 THEN 1 ELSE 0 END AS is_new_group, SUM(CASE WHEN " + "prev_data_id IS NULL OR data_id - prev_data_id > 1 THEN 1 ELSE 0 END) " + "OVER (ORDER BY data_id) AS group_number FROM lagged_samples) SELECT " + "MIN(start_date) AS start_date, MAX(end_date) AS end_date, " + "MAX(data_type) AS data_type_id, MAX(origin_product_type) AS " + "device_name, SUM(CASE WHEN sleep_value IN ('IN BED', 'ASLEEP') THEN " + "duration_minutes ELSE 0 END) AS total_duration, SUM(CASE WHEN " + "sleep_value = 'IN BED' THEN duration_minutes ELSE 0 END) AS " + "in_bed_duration, CASE WHEN SUM(duration_minutes) > 0 THEN " + "ROUND(SUM(CASE WHEN sleep_value = 'IN BED' THEN duration_minutes " + "ELSE 0 END) * 100.0 / SUM(duration_minutes), 2) ELSE 0 END AS " + "in_bed_percent, CASE WHEN SUM(duration_minutes) > 0 THEN " + "ROUND(SUM(CASE WHEN sleep_value = 'ASLEEP' THEN duration_minutes " + "ELSE 0 END) * 100.0 / SUM(duration_minutes), 2) ELSE 0 END AS " + "asleep_percent FROM grouped_samples " + "GROUP BY group_number ORDER BY MIN(start_date) ASC" + ), + "ParseSleepRowInBed", + ) + ] + + def _GetDateTimeRowValue(self, query_hash, row, value_name): + """Retrieves a date and time value from the row. + + Args: + query_hash (int): hash of the query, that uniquely identifies the query that + produced the row. + row (sqlite3.Row): row. + value_name (str): name of the value. + + Returns: + dfdatetime.CocoaTime: Date and time value or None if not available. + """ + timestamp = self._GetRowValue(query_hash, row, value_name) + if timestamp is None: + return None + + return dfdatetime_cocoa_time.CocoaTime(timestamp=timestamp) + + @staticmethod + def _MinutesToHMS(minutes_value): + """Convert minutes to HH:MM:SS string. + + Args: + minutes_value (float): number of minutes. + + Returns: + str: formatted HH:MM:SS string. + """ + if not minutes_value: + return "00:00:00" + total_seconds = int(minutes_value * 60) + hh = total_seconds // 3600 + mm = (total_seconds % 3600) // 60 + ss = total_seconds % 60 + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + def ParseSleepRowInBed(self, parser_mediator, query, row, **unused_kwargs): + """Parses a single row from the SQL query and produces an event. + + Args: + parser_mediator (ParserMediator): mediates interactions. + query (str): query that created the row. + row (sqlite3.Row): row. + """ + query_hash = hash(query) + + event_data = IOSHealthWatchBySleepPeriodEventData() + event_data.asleep_percent = self._GetRowValue(query_hash, row, "asleep_percent") + event_data.device_name = self._GetRowValue(query_hash, row, "device_name") + event_data.end_time = self._GetDateTimeRowValue(query_hash, row, "end_date") + event_data.in_bed_duration = self._GetRowValue( + query_hash, row, "in_bed_duration" + ) + event_data.in_bed_percent = self._GetRowValue(query_hash, row, "in_bed_percent") + event_data.start_time = self._GetDateTimeRowValue(query_hash, row, "start_date") + event_data.total_duration = self._GetRowValue(query_hash, row, "total_duration") + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthWatchBySleepPeriodPlugin) diff --git a/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py b/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py new file mode 100644 index 0000000000..d9dc594695 --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py @@ -0,0 +1,248 @@ +"""SQLite parser plugin for iOS Health - Watch By Sleep Period Data.""" + +from dfdatetime import cocoa_time as dfdatetime_cocoa_time + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthWatchBySleepPeriodLatestEventData(events.EventData): + """iOS Health - Watch By Sleep Period event data. + + Attributes: + awake_duration (float): duration spent awake in minutes. + awake_duration_hms (str): duration spent awake in HH:MM:SS. + awake_percent (float): percentage of time spent awake. + core_duration (float): duration spent in core sleep in minutes. + core_duration_hms (str): duration spent in core sleep in HH:MM:SS. + core_percent (float): percentage of time spent in core sleep. + date_time (dfdatetime.DateTimeValues): primary timestamp (start). + deep_duration (float): duration spent in deep sleep in minutes. + deep_duration_hms (str): duration spent in deep sleep in HH:MM:SS. + deep_percent (float): percentage of time spent in deep sleep. + end_date (dfdatetime.DateTimeValues): date and time sleep ended. + end_date_str (str): rendered end date string. + rem_duration (float): duration spent in REM sleep in minutes. + rem_duration_hms (str): duration spent in REM sleep in HH:MM:SS. + rem_percent (float): percentage of time spent in REM sleep. + start_date (dfdatetime.DateTimeValues): date and time sleep started. + start_date_str (str): rendered start date string. + total_duration (float): total duration of the sleep period in minutes. + """ + + DATA_TYPE = "ios:health:watch_by_sleep_period_latest" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.awake_duration = None + self.awake_duration_hms = None + self.awake_percent = None + self.core_duration = None + self.core_duration_hms = None + self.core_percent = None + self.date_time = None + self.deep_duration = None + self.deep_duration_hms = None + self.deep_percent = None + self.end_date = None + self.end_date_str = None + self.rem_duration = None + self.rem_duration_hms = None + self.rem_percent = None + self.start_date = None + self.start_date_str = None + self.total_duration = None + + +class IOSHealthWatchBySleepPeriodLatestPlugin(interface.SQLitePlugin): + """SQLite parser plugin for iOS Health Watch By Sleep Period (iOS 17+).""" + + NAME = "ios_health_watch_by_sleep_period_latest" + DATA_FORMAT = ( + "iOS Health Watch By Sleep Period Data from healthdb_secure.sqlite (iOS 17+)" + ) + + REQUIRED_STRUCTURE = { + "samples": frozenset(["data_id", "start_date", "end_date"]), + "category_samples": frozenset(["data_id", "value"]), + "data_provenances": frozenset(["origin_product_type"]), + "objects": frozenset(["data_id"]), + } + + QUERIES = [ + ( + ( + "WITH lagged_samples AS (SELECT s.start_date, s.end_date, s.data_id, " + "s.data_type, cs.value, LAG(s.data_id) OVER (ORDER BY s.data_id) AS " + "prev_data_id, CASE WHEN cs.value = 2 THEN 'AWAKE' WHEN cs.value = 3 " + "THEN 'CORE' WHEN cs.value = 4 THEN 'DEEP' WHEN cs.value = 5 THEN " + "'REM' END AS sleep_value FROM samples s LEFT JOIN category_samples " + "cs ON s.data_id = cs.data_id LEFT JOIN objects o ON s.data_id = " + "o.data_id LEFT JOIN data_provenances dp ON o.provenance = dp.rowid " + "WHERE s.data_type = 63 AND cs.value NOT IN (0, 1) AND " + "dp.origin_product_type LIKE '%Watch%'), grouped_samples AS (SELECT *, " + "CASE WHEN data_id - prev_data_id > 1 OR prev_data_id IS NULL THEN 1 " + "ELSE 0 END AS is_new_group, SUM(CASE WHEN data_id - prev_data_id > 1 " + "OR prev_data_id IS NULL THEN 1 ELSE 0 END) OVER (ORDER BY data_id) AS " + "group_number FROM lagged_samples) SELECT MIN(start_date) AS " + "start_date, MAX(end_date) AS end_date, SUM(CASE WHEN sleep_value IN " + "('AWAKE', 'REM', 'CORE', 'DEEP') THEN (end_date - start_date) / 60.0 " + "ELSE 0 END) AS total_duration, SUM(CASE WHEN sleep_value = 'AWAKE' " + "THEN (end_date - start_date) / 60.0 ELSE 0 END) AS awake_duration, " + "SUM(CASE WHEN sleep_value = 'REM' THEN (end_date - start_date) / 60.0 " + "ELSE 0 END) AS rem_duration, SUM(CASE WHEN sleep_value = 'CORE' THEN " + "(end_date - start_date) / 60.0 ELSE 0 END) AS core_duration, SUM(CASE " + "WHEN sleep_value = 'DEEP' THEN (end_date - start_date) / 60.0 ELSE 0 " + "END) AS deep_duration FROM grouped_samples GROUP BY group_number " + "ORDER BY MIN(start_date) ASC" + ), + "ParseSleepRow", + ) + ] + + SCHEMAS = { + "samples": ( + "CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date " + "INTEGER, end_date INTEGER, data_type TEXT)" + ), + "category_samples": ( + "CREATE TABLE category_samples (data_id INTEGER PRIMARY KEY, value " + "INTEGER)" + ), + "data_provenances": ( + "CREATE TABLE data_provenances (rowid INTEGER PRIMARY KEY, " + "origin_product_type TEXT)" + ), + "objects": ( + "CREATE TABLE objects (data_id INTEGER PRIMARY KEY, provenance INTEGER)" + ), + } + + REQUIRE_SCHEMA_MATCH = False + + def _GetCocoaDateTime(self, query_hash, row, value_name): + """Returns dfdatetime.CocoaTime from a numeric Cocoa timestamp. + + Args: + query_hash (int): hash of the query. + row (sqlite3.Row): row. + value_name (str): name of the value. + + Returns: + dfdatetime.CocoaTime: date and time value or None. + """ + timestamp = self._GetRowValue(query_hash, row, value_name) + if timestamp is None: + return None + try: + ts_float = float(timestamp) + if ts_float == 0: + return None + return dfdatetime_cocoa_time.CocoaTime(timestamp=ts_float) + except (TypeError, ValueError): + return None + + def _CopyToRfc3339String(self, dfdt): + """Returns RFC3339 string from a dfdatetime object or None. + + Args: + dfdt (dfdatetime.DateTimeValues): date time value. + + Returns: + str: formatted date time string or None. + """ + if dfdt is None: + return None + try: + return getattr( + dfdt, "CopyToDateTimeStringRFC3339", dfdt.CopyToDateTimeString + )() + except (AttributeError, TypeError, ValueError): + return None + + @staticmethod + def _SecondsToHMS(minutes_value): + """Convert minutes to HH:MM:SS string. + + Args: + minutes_value (float): number of minutes. + + Returns: + str: formatted HH:MM:SS string. + """ + try: + total_seconds = int(float(minutes_value) * 60) + except (TypeError, ValueError): + return "00:00:00" + hh = total_seconds // 3600 + mm = (total_seconds % 3600) // 60 + ss = total_seconds % 60 + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + @staticmethod + def _CalculatePercent(part, total): + """Calculates percentage. + + Args: + part (float): part value. + total (float): total value. + + Returns: + float: calculated percentage. + """ + try: + return round((float(part) / float(total)) * 100, 2) if total else 0.0 + except (TypeError, ValueError): + return 0.0 + + def ParseSleepRow(self, parser_mediator, query, row, **unused_kwargs): + """Parses a Watch By Sleep Period row. + + Args: + parser_mediator (ParserMediator): mediates interactions. + query (str): query that created the row. + row (sqlite3.Row): row. + """ + if not row: + return + + qh = hash(query) + event_data = IOSHealthWatchBySleepPeriodLatestEventData() + + start_dt = self._GetCocoaDateTime(qh, row, "start_date") + end_dt = self._GetCocoaDateTime(qh, row, "end_date") + event_data.start_date = start_dt + event_data.end_date = end_dt + event_data.start_date_str = self._CopyToRfc3339String(start_dt) + event_data.end_date_str = self._CopyToRfc3339String(end_dt) + event_data.date_time = start_dt + + event_data.total_duration = self._GetRowValue(qh, row, "total_duration") + event_data.awake_duration = self._GetRowValue(qh, row, "awake_duration") + event_data.rem_duration = self._GetRowValue(qh, row, "rem_duration") + event_data.core_duration = self._GetRowValue(qh, row, "core_duration") + event_data.deep_duration = self._GetRowValue(qh, row, "deep_duration") + + event_data.awake_duration_hms = self._SecondsToHMS(event_data.awake_duration) + event_data.rem_duration_hms = self._SecondsToHMS(event_data.rem_duration) + event_data.core_duration_hms = self._SecondsToHMS(event_data.core_duration) + event_data.deep_duration_hms = self._SecondsToHMS(event_data.deep_duration) + + total = event_data.total_duration or 1 + event_data.awake_percent = self._CalculatePercent( + event_data.awake_duration, total + ) + event_data.rem_percent = self._CalculatePercent(event_data.rem_duration, total) + event_data.core_percent = self._CalculatePercent( + event_data.core_duration, total + ) + event_data.deep_percent = self._CalculatePercent( + event_data.deep_duration, total + ) + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthWatchBySleepPeriodLatestPlugin) diff --git a/plaso/parsers/sqlite_plugins/ios_health_watch_worn_data.py b/plaso/parsers/sqlite_plugins/ios_health_watch_worn_data.py new file mode 100644 index 0000000000..e67f2b830f --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_watch_worn_data.py @@ -0,0 +1,119 @@ +"""SQLite parser plugin for iOS Health Watch Worn Data.""" + +from dfdatetime import cocoa_time as dfdatetime_cocoa_time + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthWatchWornEventData(events.EventData): + """iOS Health Watch Worn Data event. + + Attributes: + date_time (dfdatetime.DateTimeValues): primary timestamp for timeline. + end_time (dfdatetime.DateTimeValues): date and time the sample ended. + hours_off_before_next (int): hours the watch was off before the next period. + hours_worn (int): total hours the watch was worn. + last_worn_time_str (str): rendered last worn time string. + start_time (dfdatetime.DateTimeValues): date and time the sample started. + """ + + DATA_TYPE = "ios:health:watch_worn" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.date_time = None + self.end_time = None + self.hours_off_before_next = None + self.hours_worn = None + self.last_worn_time_str = None + self.start_time = None + + +class IOSHealthWatchWornPlugin(interface.SQLitePlugin): + """SQLite parser plugin for iOS Health Watch Worn Data.""" + + NAME = "ios_health_watch_worn_data" + DATA_FORMAT = "iOS Health Watch Worn Data from healthdb_secure.sqlite" + + REQUIRED_STRUCTURE = {"samples": frozenset(["start_date", "end_date", "data_type"])} + + QUERIES = [ + ( + ( + "WITH TimeData AS (SELECT s.start_date AS start_cocoa, s.end_date AS " + "end_cocoa, LAG(s.end_date) OVER (ORDER BY s.start_date) AS " + "prev_end_cocoa FROM samples s WHERE s.data_type IN (70, '70')), " + "PeriodData AS (SELECT *, (start_cocoa - prev_end_cocoa) AS " + "gap_seconds, CASE WHEN (start_cocoa - prev_end_cocoa) > 3600 THEN 1 " + "ELSE 0 END AS new_period FROM TimeData), PeriodGroup AS (SELECT *, " + "SUM(new_period) OVER (ORDER BY start_cocoa ROWS BETWEEN UNBOUNDED " + "PRECEDING AND CURRENT ROW) AS period_id FROM PeriodData), Summary AS " + "(SELECT period_id, MIN(start_cocoa) AS period_start_cocoa, " + "MAX(end_cocoa) AS period_end_cocoa, CAST((MAX(end_cocoa) - " + "MIN(start_cocoa)) / 3600 AS INT) AS hours_worn FROM PeriodGroup " + "GROUP BY period_id) SELECT s1.period_start_cocoa AS start_cocoa, " + "s1.hours_worn AS hours_worn, s1.period_end_cocoa AS end_cocoa, " + "CAST((s2.period_start_cocoa - s1.period_end_cocoa) / 3600 AS INT) " + "AS hours_off_before_next FROM Summary s1 LEFT JOIN Summary s2 ON " + "s1.period_id + 1 = s2.period_id" + ), + "ParseWatchWornRow", + ) + ] + + SCHEMAS = { + "samples": ( + "CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date " + "INTEGER, end_date INTEGER, data_type TEXT)" + ) + } + + REQUIRE_SCHEMA_MATCH = False + + def _GetCocoaDateTime(self, query_hash, row, value_name): + """Returns dfdatetime.CocoaTime from a numeric Cocoa timestamp. + + Args: + query_hash (int): hash of the query. + row (sqlite3.Row): row. + value_name (str): name of the value. + + Returns: + dfdatetime.CocoaTime: date and time value or None. + """ + timestamp = self._GetRowValue(query_hash, row, value_name) + if timestamp is None: + return None + try: + ts_float = float(timestamp) + if ts_float == 0: + return None + return dfdatetime_cocoa_time.CocoaTime(timestamp=ts_float) + except (ValueError, TypeError): + return None + + def ParseWatchWornRow(self, parser_mediator, query, row, **unused_kwargs): + """Parses a Watch Worn summary row (one worn period). + + Args: + parser_mediator (ParserMediator): mediates interactions. + query (str): query that created the row. + row (sqlite3.Row): row. + """ + query_hash = hash(query) + + event_data = IOSHealthWatchWornEventData() + event_data.end_time = self._GetCocoaDateTime(query_hash, row, "end_cocoa") + event_data.hours_off_before_next = self._GetRowValue( + query_hash, row, "hours_off_before_next" + ) + event_data.hours_worn = self._GetRowValue(query_hash, row, "hours_worn") + event_data.start_time = self._GetCocoaDateTime(query_hash, row, "start_cocoa") + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthWatchWornPlugin) diff --git a/plaso/parsers/sqlite_plugins/ios_health_workouts_latest.py b/plaso/parsers/sqlite_plugins/ios_health_workouts_latest.py new file mode 100644 index 0000000000..015f7657eb --- /dev/null +++ b/plaso/parsers/sqlite_plugins/ios_health_workouts_latest.py @@ -0,0 +1,322 @@ +"""SQLite parser plugin for iOS 16 and 17 Health Workouts.""" + +from dfdatetime import cocoa_time as dfdatetime_cocoa_time + +from plaso.containers import events +from plaso.parsers import sqlite +from plaso.parsers.sqlite_plugins import interface + + +class IOSHealthWorkoutsLatestEventData(events.EventData): + """iOS 16 and 17 Health Workouts (workout_activities) event data. + + Attributes: + activity_type (int): RAW code from workout_activities.activity_type. + added_time (dfdatetime.DateTimeValues): data and time the sample was added to the + database. + average_mets (float): average METs value. + avg_heart_rate_bpm (int): average heart rate in BPM. + core_duration (float): duration spent in core sleep (if applicable). + duration_in_minutes (float): duration_seconds / 60. + end_time (dfdatetime.DateTimeValues): date and time the sample ended. + goal (str): workout goal value. + goal_type (int): RAW code from workouts.goal_type. + hardware (str): source_devices.hardware. + humidity_percent (int): weather humidity. + latitude (float): weather location latitude. + location_type (int): RAW code from workout_activities.location_type. + longitude (float): weather location longitude. + max_ground_elevation_m (float): maximum ground elevation. + max_heart_rate_bpm (int): maximum heart rate in BPM. + min_ground_elevation_m (float): minimum ground elevation. + min_heart_rate_bpm (int): minimum heart rate in BPM. + software_version (str): data_provenances.source_version. + source (str): sources.name. + start_time (dfdatetime.DateTimeValues): date and time the sample started. + temperature_c (float): temperature in Celsius. + temperature_f (float): temperature in Fahrenheit. + timezone (str): data_provenances.tz_name. + total_active_energy_kcal (float): active energy burned. + total_distance_km (float): distance in kilometers. + total_distance_miles (float): distance in miles. + total_resting_energy_kcal (float): resting energy burned. + total_time_duration (str): HH:MM:SS (samples.end - samples.start). + workout_duration (str): HH:MM:SS from duration seconds. + """ + + DATA_TYPE = "ios:health:workouts_ios16" + + def __init__(self): + """Initializes event data.""" + super().__init__(data_type=self.DATA_TYPE) + self.activity_type = None + self.added_time = None + self.average_mets = None + self.avg_heart_rate_bpm = None + self.duration_in_minutes = None + self.end_time = None + self.goal = None + self.goal_type = None + self.hardware = None + self.humidity_percent = None + self.latitude = None + self.location_type = None + self.longitude = None + self.max_ground_elevation_m = None + self.max_heart_rate_bpm = None + self.min_ground_elevation_m = None + self.min_heart_rate_bpm = None + self.software_version = None + self.source = None + self.start_time = None + self.temperature_c = None + self.temperature_f = None + self.timezone = None + self.total_active_energy_kcal = None + self.total_distance_km = None + self.total_distance_miles = None + self.total_resting_energy_kcal = None + self.total_time_duration = None + self.workout_duration = None + + +class IOSHealthWorkoutsLatestPlugin(interface.SQLitePlugin): + """SQLite parser plugin for Health Workouts (iOS 16/17).""" + + NAME = "ios_health_workouts_latest" + DATA_FORMAT = ( + "iOS Health Workouts (iOS 16/17) workout_activities schema in " + "healthdb_secure.sqlite" + ) + + REQUIRED_STRUCTURE = { + "workout_activities": frozenset( + [ + "ROWID", + "owner_id", + "activity_type", + "start_date", + "end_date", + "duration", + "location_type", + ] + ), + "samples": frozenset(["data_id", "start_date", "end_date"]), + "workouts": frozenset(["data_id", "total_distance", "goal_type", "goal"]), + "workout_statistics": frozenset( + ["workout_activity_id", "data_type", "quantity"] + ), + "metadata_keys": frozenset(["ROWID", "key"]), + "metadata_values": frozenset( + ["object_id", "key_id", "numerical_value", "string_value"] + ), + "objects": frozenset(["data_id", "creation_date", "provenance"]), + "data_provenances": frozenset( + ["ROWID", "source_version", "tz_name", "device_id", "source_id"] + ), + "source_devices": frozenset(["ROWID", "hardware"]), + "sources": frozenset(["ROWID", "name"]), + } + + QUERIES = [ + ( + ( + "SELECT wa.start_date AS start_cocoa, wa.end_date AS end_cocoa, " + "wa.activity_type AS activity_type_code, wa.location_type AS " + "location_type_code, wa.duration AS duration_seconds, s.start_date AS " + "sample_start_cocoa, s.end_date AS sample_end_cocoa, w.total_distance " + "AS total_distance_km, w.goal_type AS goal_type_code, w.goal AS " + "goal_value, MAX(CASE WHEN ws.data_type = 10 THEN " + "ROUND(ws.quantity, 2) END) AS total_active_kcal, MAX(CASE WHEN " + "ws.data_type = 9 THEN ROUND(ws.quantity, 2) END) AS " + "total_resting_kcal, MAX(CASE WHEN mk.key='HKAverageMETs' THEN " + "ROUND(mv.numerical_value,1) END) AS avg_mets, MAX(CASE WHEN " + "mk.key='_HKPrivateWorkoutMinHeartRate' THEN " + "CAST(ROUND(mv.numerical_value*60) AS INT) END) AS min_hr_bpm, " + "MAX(CASE WHEN mk.key='_HKPrivateWorkoutMaxHeartRate' THEN " + "CAST(ROUND(mv.numerical_value*60) AS INT) END) AS max_hr_bpm, " + "MAX(CASE WHEN mk.key='_HKPrivateWorkoutAverageHeartRate' THEN " + "CAST(ROUND(mv.numerical_value*60) AS INT) END) AS avg_hr_bpm, " + "MAX(CASE WHEN mk.key='HKWeatherTemperature' THEN " + "ROUND(mv.numerical_value,2) END) AS temp_f, MAX(CASE WHEN " + "mk.key='HKWeatherHumidity' THEN CAST(mv.numerical_value AS INT) END) " + "AS humidity_pct, MAX(CASE WHEN " + "mk.key='_HKPrivateWorkoutWeatherLocationCoordinatesLatitude' THEN " + "mv.numerical_value END) AS lat, MAX(CASE WHEN " + "mk.key='_HKPrivateWorkoutWeatherLocationCoordinatesLongitude' THEN " + "mv.numerical_value END) AS lon, MAX(CASE WHEN " + "mk.key='_HKPrivateWorkoutMinGroundElevation' THEN " + "ROUND(mv.numerical_value,2) END) AS min_elev_m, MAX(CASE WHEN " + "mk.key='_HKPrivateWorkoutMaxGroundElevation' THEN " + "ROUND(mv.numerical_value,2) END) AS max_elev_m, " + "source_devices.hardware AS hardware, sources.name AS source_name, " + "dp.source_version AS software_version, dp.tz_name AS timezone_name, " + "o.creation_date AS added_cocoa FROM workout_activities AS wa LEFT " + "JOIN workouts AS w ON w.data_id = wa.owner_id LEFT JOIN " + "workout_statistics AS ws ON ws.workout_activity_id = wa.ROWID LEFT " + "JOIN samples AS s ON s.data_id = w.data_id LEFT JOIN metadata_values " + "AS mv ON mv.object_id = wa.owner_id LEFT JOIN metadata_keys AS mk ON " + "mk.ROWID = mv.key_id LEFT JOIN objects AS o ON o.data_id = " + "wa.owner_id LEFT JOIN data_provenances AS dp ON dp.ROWID = " + "o.provenance LEFT JOIN source_devices ON source_devices.ROWID = " + "dp.device_id LEFT JOIN sources ON sources.ROWID = dp.source_id GROUP " + "BY wa.ROWID" + ), + "ParseWorkoutActivitiesRow", + ) + ] + + SCHEMAS = { + "workout_activities": ( + "CREATE TABLE workout_activities (ROWID INTEGER PRIMARY KEY, " + "owner_id INTEGER, activity_type INTEGER, start_date INTEGER, " + "end_date INTEGER, duration REAL, location_type INTEGER)" + ), + "workouts": ( + "CREATE TABLE workouts (data_id INTEGER PRIMARY KEY, total_distance " + "REAL, goal_type INTEGER, goal TEXT)" + ), + "samples": ( + "CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date " + "INTEGER, end_date INTEGER)" + ), + "workout_statistics": ( + "CREATE TABLE workout_statistics (workout_activity_id INTEGER, " + "data_type INTEGER, quantity REAL)" + ), + "source_devices": ( + "CREATE TABLE source_devices (ROWID INTEGER PRIMARY KEY, hardware " "TEXT)" + ), + "sources": ("CREATE TABLE sources (ROWID INTEGER PRIMARY KEY, name TEXT)"), + } + + REQUIRE_SCHEMA_MATCH = False + + def _GetDateTimeRowValue(self, query_hash, row, value_name): + """Retrieves CocoaTime from an integer timestamp column.""" + ts = self._GetRowValue(query_hash, row, value_name) + if ts is None: + return None + try: + return dfdatetime_cocoa_time.CocoaTime(timestamp=int(ts)) + except (ValueError, TypeError): + return None + + def _CopyToRfc3339String(self, dfdt): + """Returns RFC3339/ISO string from a dfdatetime object or None.""" + if dfdt is None: + return None + try: + to_rfc3339 = getattr(dfdt, "CopyToDateTimeStringRFC3339", None) + if callable(to_rfc3339): + return to_rfc3339() + return dfdt.CopyToDateTimeString() + except (AttributeError, TypeError, ValueError): + return None + + def _SecondsToHMS(self, seconds_value): + """Converts seconds to 'HH:MM:SS'.""" + try: + total = int(float(seconds_value)) + except (TypeError, ValueError): + return None + hours = total // 3600 + minutes = (total % 3600) // 60 + seconds = total % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + def _DistanceToKmMiles(self, km_value): + """Converts distance to (km, miles). Assumes input in KM.""" + try: + km = float(km_value) + except (TypeError, ValueError): + return None, None + miles = km * 0.621371 + return km, miles + + def ParseWorkoutActivitiesRow(self, parser_mediator, query, row, **unused_kwargs): + """Parses a row from workout_activities local join.""" + query_hash = hash(query) + + duration_seconds = self._GetRowValue(query_hash, row, "duration_seconds") + temp_f = self._GetRowValue(query_hash, row, "temp_f") + + event_data = IOSHealthWorkoutsLatestEventData() + event_data.activity_type = self._GetRowValue( + query_hash, row, "activity_type_code" + ) + event_data.added_time = self._GetDateTimeRowValue( + query_hash, row, "added_cocoa" + ) + event_data.average_mets = self._GetRowValue(query_hash, row, "avg_mets") + event_data.avg_heart_rate_bpm = self._GetRowValue(query_hash, row, "avg_hr_bpm") + event_data.end_time = self._GetDateTimeRowValue(query_hash, row, "end_cocoa") + event_data.goal = self._GetRowValue(query_hash, row, "goal_value") + event_data.goal_type = self._GetRowValue(query_hash, row, "goal_type_code") + event_data.hardware = self._GetRowValue(query_hash, row, "hardware") or None + event_data.humidity_percent = self._GetRowValue(query_hash, row, "humidity_pct") + event_data.latitude = self._GetRowValue(query_hash, row, "lat") + event_data.location_type = self._GetRowValue( + query_hash, row, "location_type_code" + ) + event_data.longitude = self._GetRowValue(query_hash, row, "lon") + event_data.max_ground_elevation_m = self._GetRowValue( + query_hash, row, "max_elev_m" + ) + event_data.max_heart_rate_bpm = self._GetRowValue(query_hash, row, "max_hr_bpm") + event_data.min_ground_elevation_m = self._GetRowValue( + query_hash, row, "min_elev_m" + ) + event_data.min_heart_rate_bpm = self._GetRowValue(query_hash, row, "min_hr_bpm") + event_data.software_version = self._GetRowValue( + query_hash, row, "software_version" + ) + event_data.source = self._GetRowValue(query_hash, row, "source_name") + event_data.start_time = self._GetDateTimeRowValue( + query_hash, row, "start_cocoa" + ) + event_data.temperature_f = temp_f + event_data.timezone = self._GetRowValue(query_hash, row, "timezone_name") + event_data.workout_duration = self._SecondsToHMS(duration_seconds) + + if duration_seconds is not None: + try: + event_data.duration_in_minutes = float(duration_seconds) / 60.0 + except (TypeError, ValueError): + event_data.duration_in_minutes = None + + sample_start = self._GetRowValue(query_hash, row, "sample_start_cocoa") + sample_end = self._GetRowValue(query_hash, row, "sample_end_cocoa") + try: + if sample_end is not None and sample_start is not None: + total_secs = int(sample_end) - int(sample_start) + event_data.total_time_duration = self._SecondsToHMS(total_secs) + except (TypeError, ValueError): + event_data.total_time_duration = None + + km_raw = self._GetRowValue(query_hash, row, "total_distance_km") + km, miles = self._DistanceToKmMiles(km_raw) + event_data.total_distance_km = round(km, 2) if km is not None else None + event_data.total_distance_miles = round(miles, 2) if miles is not None else None + + act = self._GetRowValue(query_hash, row, "total_active_kcal") + rest = self._GetRowValue(query_hash, row, "total_resting_kcal") + event_data.total_active_energy_kcal = ( + round(float(act), 2) if act is not None else None + ) + event_data.total_resting_energy_kcal = ( + round(float(rest), 2) if rest is not None else None + ) + + if temp_f is not None: + try: + event_data.temperature_c = round( + ((float(temp_f) - 32.0) * (5.0 / 9.0)), 2 + ) + except (TypeError, ValueError): + event_data.temperature_c = None + + parser_mediator.ProduceEventData(event_data) + + +sqlite.SQLiteParser.RegisterPlugin(IOSHealthWorkoutsLatestPlugin) diff --git a/test_data/ios/healthdb_secure_iOS_13_3_1.sqlite b/test_data/ios/healthdb_secure_iOS_13_3_1.sqlite new file mode 100644 index 0000000000..7de603d746 Binary files /dev/null and b/test_data/ios/healthdb_secure_iOS_13_3_1.sqlite differ diff --git a/test_data/ios/healthdb_secure_iOS_15.sqlite b/test_data/ios/healthdb_secure_iOS_15.sqlite new file mode 100644 index 0000000000..f6f83de186 Binary files /dev/null and b/test_data/ios/healthdb_secure_iOS_15.sqlite differ diff --git a/test_data/ios/healthdb_secure_iOS_16.sqlite b/test_data/ios/healthdb_secure_iOS_16.sqlite new file mode 100644 index 0000000000..61f794bab8 Binary files /dev/null and b/test_data/ios/healthdb_secure_iOS_16.sqlite differ diff --git a/test_data/ios/healthdb_secure_iOS_17.sqlite b/test_data/ios/healthdb_secure_iOS_17.sqlite new file mode 100644 index 0000000000..ed10e2d536 Binary files /dev/null and b/test_data/ios/healthdb_secure_iOS_17.sqlite differ diff --git a/tests/cli/extraction_tool.py b/tests/cli/extraction_tool.py index 5d1aeba6e9..3f332bffb1 100644 --- a/tests/cli/extraction_tool.py +++ b/tests/cli/extraction_tool.py @@ -428,7 +428,7 @@ def testListParsersAndPlugins(self): # Note that the expected line is truncated by the cell wrapping in # the table. - expected_line = "chrome_27_history : Parser for Google Chrome 27 and later" + expected_line = "chrome_27_history : Parser for Google Chrome 27 and" self.assertIn(expected_line, lines) expected_line = "winreg_default : Parser for Windows Registry data." diff --git a/tests/parsers/sqlite_plugins/ios_health.py b/tests/parsers/sqlite_plugins/ios_health.py index 0e3b7e60ac..e830c1ee2f 100644 --- a/tests/parsers/sqlite_plugins/ios_health.py +++ b/tests/parsers/sqlite_plugins/ios_health.py @@ -53,7 +53,7 @@ def testProcess(self): "creator_device": 1, "data_type": "ios:health:achievement", "earned_date": "2020-03-23", - "sync_provenance": 1, + "synchronization_provenance": 1, "template_unique_name": "NewMoveGoalAchieved", "value_canonical_unit": "kcal", "value_in_canonical_unit": 480.0, @@ -88,6 +88,7 @@ def testProcess(self): # Check all watch sleep (sample) event data. expected_event_values = { "data_type": "ios:health:all_watch_sleep", + "duration": 29220.0, "end_time": "2020-04-04T10:37:00.000000+00:00", "sleep_state_code": 0, "start_time": "2020-04-04T02:30:00.000000+00:00", @@ -161,7 +162,7 @@ def testProcess(self): "manufacturer": "Apple Inc.", "model": "iPhone", "software": "13.3.1", - "sync_provenance": 0, + "synchronization_provenance": 0, } event_data = storage_writer.GetAttributeContainerByIndex("event_data", 11747) self.CheckEventData(event_data, expected_event_values) diff --git a/tests/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py b/tests/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py new file mode 100644 index 0000000000..0d77e66985 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_all_watch_sleep_latest.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Tests for iOS Health - All Watch Sleep (iOS 17+) SQLite plugin.""" + +import unittest + +from plaso.parsers.sqlite_plugins import ios_health_all_watch_sleep_latest + +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthAllWatchSleepLatestPluginGoldenTest(test_lib.SQLitePluginTestCase): + """Golden-value tests for the iOS Health - All Watch Sleep (stages) plugin.""" + + def testProcess(self): + """Tests the Process function on a healthdb_secure.sqlite file.""" + plugin = ios_health_all_watch_sleep_latest.IOSHealthAllWatchSleepLatestPlugin() + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_17.sqlite"], plugin + ) + # 93 watch sleep (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 93) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + # Check watch sleep (sample) event data. + expected_event_values = { + "data_type": "ios:health:all_watch_sleep_ios17", + "duration": 1140.0, + "end_time": "2024-07-23T03:44:36.786187+00:00", + "sleep_state_code": 3, + "start_time": "2024-07-23T03:25:36.786187+00:00", + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/parsers/sqlite_plugins/ios_health_source_devices_latest.py b/tests/parsers/sqlite_plugins/ios_health_source_devices_latest.py new file mode 100644 index 0000000000..94c1b602c4 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_source_devices_latest.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Tests for iOS Health Source Devices Latest SQLite database plugin.""" + +import unittest + +from plaso.parsers.sqlite_plugins import ios_health_source_devices_latest +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthSourceDevicesLatestTest(test_lib.SQLitePluginTestCase): + """Tests for iOS Health Source Devices Latest SQLite database plugin.""" + + def testProcess(self): + """Test the Process function for source devices.""" + plugin = ios_health_source_devices_latest.IOSHealthSourceDevicesLatestPlugin() + + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_17.sqlite"], plugin + ) + # 25 source device (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 25) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + expected_event_values = { + "added_time": "2023-05-19T16:03:01.287088+00:00", + "data_type": "ios:health:source_devices_latest", + "device_name": "This Is’s AirPods", + "firmware": None, + "hardware": None, + "local_identifier": "08:65:18:75:5E:75-tacl", + "manufacturer": "Apple Inc.", + "model": "0x2013", + "software": None, + "sync_identity": 1, + "sync_provenance": 53, + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py b/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py new file mode 100644 index 0000000000..95028ae610 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Tests for the iOS Health - Watch by Sleep Period InBed plugin.""" + +import unittest + +from plaso.parsers.sqlite_plugins import ios_health_watch_by_sleep_period +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthWatchBySleepPeriodPluginTest(test_lib.SQLitePluginTestCase): + """Tests for the iOS Health - Watch by Sleep Period InBed plugin.""" + + def testProcess(self): + """Test overall plugin handling including parsing a real SQLite file.""" + plugin = ios_health_watch_by_sleep_period.IOSHealthWatchBySleepPeriodPlugin() + + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_15.sqlite"], plugin + ) + # 3 watch by sleep period (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 3) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + expected_event_values = { + "asleep_percent": 100.0, + "data_type": "ios:health:watch_by_sleep_period", + "end_time": "2021-01-17T11:13:30.990108+00:00", + "in_bed_duration": 0.0, + "in_bed_percent": 0.0, + "start_time": "2020-12-31T03:56:28.330994+00:00", + "total_duration": 7493.0, + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py b/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py new file mode 100644 index 0000000000..3588de13e8 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_watch_by_sleep_period_latest.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Tests for the iOS Health Watch by Sleep Period Latest (iOS 17+) plugin.""" + +import unittest + +from plaso.parsers.sqlite_plugins import ( + ios_health_watch_by_sleep_period_latest as ios_health_watch_by_sleep, +) + +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthWatchBySleepPeriodLatestPluginTest(test_lib.SQLitePluginTestCase): + """Tests for the iOS Health Watch by Sleep Period Latest plugin.""" + + def testProcess(self): + """Test overall plugin handling including parsing a real SQLite file.""" + plugin = ios_health_watch_by_sleep.IOSHealthWatchBySleepPeriodLatestPlugin() + + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_17.sqlite"], plugin + ) + # 4 watch by sleep period (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 4) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + expected_event_values = { + "awake_duration_hms": "00:01:00", + "awake_percent": 0.24, + "core_duration_hms": "03:34:00", + "core_percent": 52.2, + "data_type": "ios:health:watch_by_sleep_period_latest", + "deep_duration_hms": "01:28:30", + "deep_percent": 21.59, + "end_date_str": "2024-07-23 10:15:36.786187", + "rem_duration_hms": "01:46:30", + "rem_percent": 25.98, + "start_date_str": "2024-07-23 03:25:36.786187", + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/parsers/sqlite_plugins/ios_health_watch_worn_data.py b/tests/parsers/sqlite_plugins/ios_health_watch_worn_data.py new file mode 100644 index 0000000000..9f3a36bd79 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_watch_worn_data.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Tests for iOS Health Watch Worn Data SQLite plugin (golden values).""" + +import unittest + +from plaso.parsers.sqlite_plugins import ios_health_watch_worn_data +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthWatchWornGoldenTest(test_lib.SQLitePluginTestCase): + """Golden-value tests for the iOS Health Watch Worn Data SQLite plugin.""" + + def testProcess(self): + """Tests the Process function on a healthdb_secure.sqlite file.""" + plugin = ios_health_watch_worn_data.IOSHealthWatchWornPlugin() + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_13_4_1.sqlite"], plugin + ) + # 19 worn data (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 19) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + # Check worn data (sample) event data. + expected_event_values = { + "data_type": "ios:health:watch_worn", + "end_time": "2020-03-22T03:00:00.000000+00:00", + "hours_off_before_next": 8, + "hours_worn": 3, + "start_time": "2020-03-22T00:00:00.000000+00:00", + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/parsers/sqlite_plugins/ios_health_workouts_latest.py b/tests/parsers/sqlite_plugins/ios_health_workouts_latest.py new file mode 100644 index 0000000000..eb669ddd59 --- /dev/null +++ b/tests/parsers/sqlite_plugins/ios_health_workouts_latest.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Tests for iOS 16/17 Health Workouts (latest, workout_activities) plugin.""" + +import unittest + +from plaso.parsers.sqlite_plugins import ios_health_workouts_latest +from tests.parsers.sqlite_plugins import test_lib + + +class IOSHealthWorkoutsLatestTest(test_lib.SQLitePluginTestCase): + """Tests for iOS Health Workouts latest SQLite database plugin (LOCAL).""" + + def testProcess(self): + """Test the Process function.""" + plugin = ios_health_workouts_latest.IOSHealthWorkoutsLatestPlugin() + storage_writer = self._ParseDatabaseFileWithPlugin( + ["ios", "healthdb_secure_iOS_17.sqlite"], plugin + ) + # 29 workouts (sample) event data + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + "event_data" + ) + self.assertEqual(number_of_event_data, 29) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + "extraction_warning" + ) + self.assertEqual(number_of_warnings, 0) + + number_of_recovery_warnings = storage_writer.GetNumberOfAttributeContainers( + "recovery_warning" + ) + self.assertEqual(number_of_recovery_warnings, 0) + + # Check workouts (sample) event data. + expected_event_values = { + "activity_type": 37, + "added_time": "2023-04-19T18:30:14.000000+00:00", + "average_mets": None, + "avg_heart_rate_bpm": None, + "data_type": "ios:health:workouts_ios16", + "end_time": "2023-04-15T15:00:29.000000+00:00", + "goal": None, + "goal_type": 0, + "hardware": None, + "humidity_percent": None, + "latitude": None, + "location_type": 3, + "longitude": None, + "max_ground_elevation_m": None, + "max_heart_rate_bpm": None, + "min_ground_elevation_m": None, + "min_heart_rate_bpm": None, + "software_version": "36", + "source": "Connect", + "start_time": "2023-04-15T14:34:28.000000+00:00", + "temperature_c": None, + "temperature_f": None, + "timezone": "America/New_York", + "total_active_energy_kcal": 439.0, + "total_distance_km": 5.3, + "total_distance_miles": 3.3, + "total_resting_energy_kcal": None, + "total_time_duration": "00:26:01", + "workout_duration": "00:26:01", + } + event_data = storage_writer.GetAttributeContainerByIndex("event_data", 0) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == "__main__": + unittest.main()