diff --git a/HealthImport/EventDetailView.swift b/HealthImport/EventDetailView.swift index 2ef0afd..573da07 100644 --- a/HealthImport/EventDetailView.swift +++ b/HealthImport/EventDetailView.swift @@ -3,15 +3,23 @@ import SwiftUI struct EventDetailView: View { let event: WorkoutEvent - + + var metadata: [(key: String, value: Any)] { + event.metadata.sorted { $0.key < $1.key } + } + var body: some View { List { DetailRow("Date", date: event.date) DetailRow("Type", value: event.type) DetailRow("Duration", duration: event.duration) - DetailRow("Metadata", value: event.metadata) DetailRow("Session UUID", value: event.sessionUUID) DetailRow("Error", value: event.error) + Section("Metadata") { + ForEach(metadata, id: \.key) { (key, value) in + DetailRow(key, value: "\(value)") + } + } } .navigationTitle("Event") } diff --git a/HealthImport/Model/WorkoutEvent+SQLite.swift b/HealthImport/Model/WorkoutEvent+SQLite.swift index c51dcd6..ab765ec 100644 --- a/HealthImport/Model/WorkoutEvent+SQLite.swift +++ b/HealthImport/Model/WorkoutEvent+SQLite.swift @@ -42,11 +42,18 @@ extension WorkoutEvent { date: Date(timeIntervalSinceReferenceDate: row[columnDate]), type: .init(rawValue: row[columnType])!, duration: row[columnDuration], - metadata: row[columnMetadata], + metadata: metadata(row[columnMetadata]), sessionUUID: row[columnSessionUUID], error: row[columnError]) } + private static func metadata(_ data: Data?) -> [String : Any] { + guard let data else { + return [:] + } + return decode(metadata: data) + } + static func createTable(in database: Database) throws { // try database.execute("CREATE TABLE workout_events (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE, date REAL NOT NULL, type INTEGER NOT NULL, duration REAL NOT NULL, metadata BLOB, session_uuid BLOB, error BLOB)") try database.run(table.create { t in @@ -71,7 +78,7 @@ extension WorkoutEvent { columnDate <- element.date.timeIntervalSinceReferenceDate, columnType <- element.type.rawValue, columnDuration <- element.duration, - columnMetadata <- element.metadata, + columnMetadata <- encode(metadata: element.metadata), columnSessionUUID <- element.sessionUUID, columnError <- element.error) ) diff --git a/HealthImport/Model/WorkoutEvent.swift b/HealthImport/Model/WorkoutEvent.swift index db6f499..0840695 100644 --- a/HealthImport/Model/WorkoutEvent.swift +++ b/HealthImport/Model/WorkoutEvent.swift @@ -1,5 +1,6 @@ import Foundation import HealthKit +import BinaryCodable struct WorkoutEvent { @@ -9,7 +10,7 @@ struct WorkoutEvent { let duration: TimeInterval - let metadata: Data? + let metadata: [String : Any] let sessionUUID: Data? @@ -20,7 +21,7 @@ struct WorkoutEvent { extension WorkoutEvent: Equatable { static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool { - lhs.date == rhs.date + lhs.date == rhs.date && lhs.type == rhs.type && lhs.duration == rhs.duration } } @@ -35,5 +36,140 @@ extension WorkoutEvent: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(date) + hasher.combine(type.rawValue) + hasher.combine(duration) + } +} + +extension WorkoutEvent: Identifiable { + + var id: Double { + date.timeIntervalSinceReferenceDate * Double(type.rawValue) * duration + } +} + +extension WorkoutEvent { + + static func decode(metadata data: Data) -> [String : Any] { + let metadata: WorkoutEventMetadata + do { + metadata = try ProtobufDecoder.decode(WorkoutEventMetadata.self, from: data) + } catch { + print("Failed to decode event metadata: \(error)") + print(data.hex) + return [:] + } + + return metadata.elements.reduce(into: [:]) { dict, element in + guard let value = element.value else { + print("No value for metadata element \(element)") + print(data.hex) + return + } + dict[element.key] = value + } + } + + static func encode(metadata: [String : Any]) -> Data? { + let wrapper = WorkoutEventMetadata(elements: metadata.compactMap { + .init(key: $0.key, value: $0.value) + }) + guard !wrapper.elements.isEmpty else { + return nil + } + do { + return try ProtobufEncoder().encode(wrapper) + } catch { + print("Failed to encode event metadata: \(error)") + return nil + } + } +} + +private struct WorkoutEventMetadata { + + let elements: [Element] +} + +extension WorkoutEventMetadata.Element { + + var value: Any? { + if let unsignedValue { + return unsignedValue + } + if let quantity { + return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value) + } + return UInt(0) + } + + init?(key: String, value: Any) { + self.key = key + + if let value = value as? UInt { + self.unsignedValue = value + self.quantity = nil + return + } + guard let value = value as? HKQuantity else { + print("Unknown value type for metadata key \(key): \(value)") + return nil + } + self.unsignedValue = nil + + if value.is(compatibleWith: .meter()) { + self.quantity = .init(value: value.doubleValue(for: .meter()), unit: "m") + } else if value.is(compatibleWith: .second()) { + self.quantity = .init(value: value.doubleValue(for: .second()), unit: "s") + } else { + print("Unhandled quantity type for metadata key \(key): \(value)") + return nil + } + } +} + +extension WorkoutEventMetadata { + + struct Element { + + let key: String + + let unsignedValue: UInt? + + let quantity: Quantity? + } +} + +extension WorkoutEventMetadata.Element { + + struct Quantity { + + let value: Double + + let unit: String + } +} + +extension WorkoutEventMetadata: Codable { + + enum CodingKeys: Int, CodingKey { + case elements = 1 + } +} + +extension WorkoutEventMetadata.Element: Codable { + + enum CodingKeys: Int, CodingKey { + case key = 1 + case unsignedValue = 4 + case quantity = 6 + } +} + +extension WorkoutEventMetadata.Element.Quantity: Codable { + + enum CodingKeys: Int, CodingKey { + case value = 1 + case unit = 2 } } diff --git a/HealthImport/Preview Content/WorkoutEvent+Mock.swift b/HealthImport/Preview Content/WorkoutEvent+Mock.swift index b439070..d2d3990 100644 --- a/HealthImport/Preview Content/WorkoutEvent+Mock.swift +++ b/HealthImport/Preview Content/WorkoutEvent+Mock.swift @@ -8,31 +8,31 @@ extension WorkoutEvent { .init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), type: .init(rawValue: 7)!, duration: 1114.56374406815, - metadata: .init(hex: mock1Event1Metadata)!, + metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event1Metadata)!), sessionUUID: nil, error: nil), .init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), type: .init(rawValue: 7)!, duration: 1972.17168283463, - metadata: .init(hex: mock1Event2Metadata)!, + metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event2Metadata)!), sessionUUID: nil, error: nil), .init(date: .init(timeIntervalSinceReferenceDate: 702112942.707113), type: .init(rawValue: 1)!, duration: 0.0, - metadata: nil, + metadata: [:], sessionUUID: nil, error: nil), .init(date: .init(timeIntervalSinceReferenceDate: 702113161.221132), type: .init(rawValue: 2)!, duration: 0.0, - metadata: nil, + metadata: [:], sessionUUID: nil, error: nil), ] } } -private let mock1Event1Metadata = "0a370a275f484b507269766174654d65746164617461546f74616c44697374616e63655175616e74697479320c090000000000408f4012016d0a240a205f484b507269766174654d6574616461746149735061727469616c53706c697420000a3d0a2d5f484b507269766174654d6574616461746153706c69744163746976654475726174696f6e5175616e74697479320c098d1f2246416a91401201730a370a275f484b507269766174654d6574616461746153706c697444697374616e63655175616e74697479320c090000000000408f4012016d0a280a245f484b50726976617465576f726b6f75745365676d656e744576656e745375627479706520010a2a0a265f484b507269766174654d6574616461746153706c69744d6561737572696e6753797374656d2001" +private let mock1Event1Metadata = "0a370a275f484b507269766174654d65746164617461546f74616c44697374616e63655175616e74697479320c090000000000408f4012016d0a240a205f484b507269766174654d6574616461746149735061727469616c53706c697420000a3d0a2d5f484b507269766174654d6574616461746153706c69744163746976654475726174696f6e5175616e74697479320c098d1f2246416a91401201730a370a275f484b507269766174654d6574616461746153706c697444697374616e63655175616e74697479320c090000000000408f4012016d0a280a245f484b50726976617465576f726b6f75745365676d656e744576656e745375627479706520010a2a0a265f484b507269766174654d6574616461746153706c69744d6561737572696e6753797374656d2001" private let mock1Event2Metadata = "0a370a275f484b507269766174654d65746164617461546f74616c44697374616e63655175616e74697479320c094c3789416025994012016d0a240a205f484b507269766174654d6574616461746149735061727469616c53706c697420000a3d0a2d5f484b507269766174654d6574616461746153706c69744163746976654475726174696f6e5175616e74697479320c09882da1cdafd09e401201730a370a275f484b507269766174654d6574616461746153706c697444697374616e63655175616e74697479320c094c3789416025994012016d0a280a245f484b50726976617465576f726b6f75745365676d656e744576656e745375627479706520010a2a0a265f484b507269766174654d6574616461746153706c69744d6561737572696e6753797374656d2002" diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index b55346b..3050770 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -30,7 +30,7 @@ struct WorkoutDetailView: View { } if !workout.events.isEmpty { Section("Events") { - ForEach(workout.events, id: \.date) { event in + ForEach(workout.events) { event in NavigationLink(value: event) { DetailRow(event.type.description, date: event.date) }