diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index be518e4..8963994 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -51,7 +51,7 @@ E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */; }; E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */; }; E27BC68E2B5FCBD5003A8873 /* WorkoutEventsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */; }; - E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; }; + E27BC6902B5FCEA4003A8873 /* WorkoutActivitiesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivitiesTable.swift */; }; E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; }; E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6932B5FD587003A8873 /* Workout+Mock.swift */; }; E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */; }; @@ -65,6 +65,7 @@ E2FDFF252B6C50A80080A7B3 /* ObjectsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */; }; E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */; }; E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; }; + E2FDFF2B2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF2A2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -109,7 +110,7 @@ E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStringsTable.swift; sourceTree = ""; }; E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = ""; }; E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsTable.swift; sourceTree = ""; }; - E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = ""; }; + E27BC68F2B5FCEA4003A8873 /* WorkoutActivitiesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivitiesTable.swift; sourceTree = ""; }; E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = ""; }; E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = ""; }; E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = ""; }; @@ -121,6 +122,7 @@ E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectsTable.swift; sourceTree = ""; }; E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvenancesTable.swift; sourceTree = ""; }; E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + E2FDFF2A2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivity+Comparable.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -216,7 +218,7 @@ 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */, E27BC6852B5FBF0B003A8873 /* Sample.swift */, 8850027E2B5C36A700E7D4DB /* Workout.swift */, - E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */, + E2FDFF2A2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift */, 885002842B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift */, ); path = Model; @@ -257,6 +259,7 @@ E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */, E201EC7C2B62930E005B83D3 /* SamplesTable.swift */, E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */, + E27BC68F2B5FCEA4003A8873 /* WorkoutActivitiesTable.swift */, E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */, 8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */, ); @@ -365,7 +368,7 @@ 885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */, E2FDFF182B6BB61D0080A7B3 /* HKHealthStoreInterface.swift in Sources */, E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */, - E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */, + E27BC6902B5FCEA4003A8873 /* WorkoutActivitiesTable.swift in Sources */, E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */, @@ -373,6 +376,7 @@ E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */, + E2FDFF2B2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift in Sources */, E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */, E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */, 8850027B2B5C35BF00E7D4DB /* WorkoutsTable.swift in Sources */, diff --git a/HealthImport/Model/HKWorkoutActivity+Comparable.swift b/HealthImport/Model/HKWorkoutActivity+Comparable.swift new file mode 100644 index 0000000..912b5f7 --- /dev/null +++ b/HealthImport/Model/HKWorkoutActivity+Comparable.swift @@ -0,0 +1,19 @@ +import Foundation +import HealthKit + +extension HKWorkoutActivity: Comparable { + + public static func < (lhs: HKWorkoutActivity, rhs: HKWorkoutActivity) -> Bool { + lhs.startDate < rhs.startDate + } +} + +extension HKWorkoutActivity { + + var externalUUID: UUID? { + guard let string = metadata?[HKMetadataKeyExternalUUID] as? String else { + return nil + } + return UUID(uuidString: string) + } +} diff --git a/HealthImport/Model/Tables/WorkoutActivitiesTable.swift b/HealthImport/Model/Tables/WorkoutActivitiesTable.swift new file mode 100644 index 0000000..42260a1 --- /dev/null +++ b/HealthImport/Model/Tables/WorkoutActivitiesTable.swift @@ -0,0 +1,119 @@ +import Foundation +import SQLite +import HealthKit + +struct WorkoutActivitiesTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + let table = Table("workout_activities") + + let rowId = Expression("ROWID") + + let uuid = Expression("uuid") + + let ownerId = Expression("owner_id") + + let isPrimaryActivity = Expression("is_primary_activity") + + let activityType = Expression("activity_type") + + let locationType = Expression("location_type") + + let swimmingLocationType = Expression("swimming_location_type") + + let lapLength = Expression("lap_length") + + let startDate = Expression("start_date") + + let endDate = Expression("end_date") + + let duration = Expression("duration") + + let metadata = Expression("metadata") + + func activities() throws -> [HKWorkoutActivity] { + try database.prepare(table).map(activity) + } + + func activity(from row: Row) throws -> HKWorkoutActivity { + let configuration = HKWorkoutConfiguration() + configuration.lapLength = try row[lapLength].map(WorkoutActivitiesTable.lapLength) + configuration.activityType = .init(rawValue: UInt(row[activityType]))! + configuration.locationType = .init(rawValue: row[locationType])! + configuration.swimmingLocationType = .init(rawValue: row[swimmingLocationType])! + + let start = Date(timeIntervalSinceReferenceDate: row[startDate]) + let end = Date(timeIntervalSinceReferenceDate: row[endDate]) + let uuid = row[uuid].uuidString + + var metadata: [String : Any] = [ : ] + metadata[HKMetadataKeyExternalUUID] = uuid + + // duration: row[columnDuration] + // isPrimaryActivity: row[columnIsPrimaryActivity] + + // metadata: row[columnMetadata] + // TODO: Decode activity metadata + return .init( + workoutConfiguration: configuration, + start: start, + end: end, + metadata: metadata) + } + + func activities(for workoutId: Int) throws -> [HKWorkoutActivity] { + try database.prepare(table.filter(ownerId == workoutId)).map(activity) + } + + func create(referencing workouts: WorkoutsTable) throws { + try database.execute("CREATE TABLE workout_activities (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE NOT NULL, owner_id INTEGER NOT NULL REFERENCES workouts(data_id) ON DELETE CASCADE, is_primary_activity INTEGER NOT NULL, activity_type INTEGER NOT NULL, location_type INTEGER NOT NULL, swimming_location_type INTEGER NOT NULL, lap_length BLOB, start_date REAL NOT NULL, end_date REAL NOT NULL, duration REAL NOT NULL, metadata BLOB)") + /* + try database.run(table.create { t in + t.column(rowId, primaryKey: .autoincrement) + t.column(uuid, unique: true) + t.column(ownerId, references: workouts.table, workouts.dataId) // TODO: ON DELETE CASCADE + t.column(isPrimaryActivity) + t.column(activityType) + t.column(locationType) + t.column(swimmingLocationType) + t.column(lapLength) + t.column(startDate) + t.column(endDate) + t.column(duration) + t.column(metadata) + }) + */ + } + + func insert(_ element: HKWorkoutActivity, isPrimaryActivity: Bool, dataId: Int) throws { + try database.run(table.insert( + uuid <- (element.externalUUID ?? element.uuid).uuidString.data(using: .utf8)!, + ownerId <- dataId, + self.isPrimaryActivity <- isPrimaryActivity, // Seems to always be 1 + activityType <- Int(element.workoutConfiguration.activityType.rawValue), + locationType <- element.workoutConfiguration.locationType.rawValue, + swimmingLocationType <- element.workoutConfiguration.swimmingLocationType.rawValue, + lapLength <- try WorkoutActivitiesTable.lapLengthData(lapLength: element.workoutConfiguration.lapLength), + startDate <- element.startDate.timeIntervalSinceReferenceDate, + endDate <- element.endDate?.timeIntervalSinceReferenceDate ?? element.startDate.addingTimeInterval(element.duration).timeIntervalSinceReferenceDate, + duration <- element.duration, + metadata <- nil) + ) + } +} + +private extension WorkoutActivitiesTable { + + static func lapLengthData(lapLength: HKQuantity?) throws -> Data? { + try lapLength.map { try NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: false) } + } + + static func lapLength(from data: Data) throws -> HKQuantity? { + try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQuantity.self, from: data) + } +} diff --git a/HealthImport/Model/Tables/WorkoutsTable.swift b/HealthImport/Model/Tables/WorkoutsTable.swift index e54132a..dedcbc5 100644 --- a/HealthImport/Model/Tables/WorkoutsTable.swift +++ b/HealthImport/Model/Tables/WorkoutsTable.swift @@ -8,9 +8,12 @@ struct WorkoutsTable { let events: WorkoutEventsTable + let activities: WorkoutActivitiesTable + init(database: Connection) { self.database = database self.events = .init(database: database) + self.activities = .init(database: database) } let table = Table("workouts") @@ -40,7 +43,7 @@ struct WorkoutsTable { let id = row[dataId] let events = try events.events(for: id, in: database) - let activities = try WorkoutActivityTable.activities(for: id, in: database) + let activities = try activities.activities(for: id) let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys) return .init( id: id, @@ -70,6 +73,7 @@ struct WorkoutsTable { func createAll() throws { try create() try events.create(referencing: self) + try activities.create(referencing: self) } func insert(_ element: Workout) throws { @@ -86,7 +90,7 @@ struct WorkoutsTable { } for activity in element.activities { - try WorkoutActivityTable.insert(activity, isPrimaryActivity: true, dataId: dataId, in: database) + try activities.insert(activity, isPrimaryActivity: true, dataId: dataId) } for (key, value) in element.metadata { diff --git a/HealthImport/Model/WorkoutActivity+SQLite.swift b/HealthImport/Model/WorkoutActivity+SQLite.swift deleted file mode 100644 index ef913c0..0000000 --- a/HealthImport/Model/WorkoutActivity+SQLite.swift +++ /dev/null @@ -1,128 +0,0 @@ -import Foundation -import SQLite -import HealthKit - -extension HKWorkoutActivity: Comparable { - - public static func < (lhs: HKWorkoutActivity, rhs: HKWorkoutActivity) -> Bool { - lhs.startDate < rhs.startDate - } -} - -enum WorkoutActivityTable { - - private static let table = Table("workout_activities") - - private static let columnId = Expression("ROWID") - - private static let columnUUID = Expression("uuid") - - private static let columnOwnerId = Expression("owner_id") - - private static let columnIsPrimaryActivity = Expression("is_primary_activity") - - private static let columnActivityType = Expression("activity_type") - - private static let columnLocationType = Expression("location_type") - - private static let columnSwimmingLocationType = Expression("swimming_location_type") - - private static let columnLapLength = Expression("lap_length") - - private static let columnStartDate = Expression("start_date") - - private static let columnEndDate = Expression("end_date") - - private static let columnDuration = Expression("duration") - - private static let columnMetadata = Expression("metadata") - - private static func readAll(in database: Connection) throws -> [HKWorkoutActivity] { - try database.prepare(table).map(activity) - } - - private static func activity(from row: Row) throws -> HKWorkoutActivity { - let configuration = HKWorkoutConfiguration() - configuration.lapLength = try row[columnLapLength].map(lapLength) - configuration.activityType = .init(rawValue: UInt(row[columnActivityType]))! - configuration.locationType = .init(rawValue: row[columnLocationType])! - configuration.swimmingLocationType = .init(rawValue: row[columnSwimmingLocationType])! - - let start = Date(timeIntervalSinceReferenceDate: row[columnStartDate]) - let end = Date(timeIntervalSinceReferenceDate: row[columnEndDate]) - let uuid = row[columnUUID].uuidString - - var metadata: [String : Any] = [ : ] - metadata[HKMetadataKeyExternalUUID] = uuid - - // duration: row[columnDuration] - // isPrimaryActivity: row[columnIsPrimaryActivity] - - // metadata: row[columnMetadata] - #warning("Decode metadata") - return .init( - workoutConfiguration: configuration, - start: start, - end: end, - metadata: metadata) - } - - static func activities(for workoutId: Int, in database: Connection) throws -> [HKWorkoutActivity] { - try database.prepare(table.filter(columnOwnerId == workoutId)).map(activity) - } - - static func create(in database: Connection) throws { - //try database.execute("CREATE TABLE workout_activities (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE NOT NULL, owner_id INTEGER NOT NULL REFERENCES workouts(data_id) ON DELETE CASCADE, is_primary_activity INTEGER NOT NULL, activity_type INTEGER NOT NULL, location_type INTEGER NOT NULL, swimming_location_type INTEGER NOT NULL, lap_length BLOB, start_date REAL NOT NULL, end_date REAL NOT NULL, duration REAL NOT NULL, metadata BLOB)") - try database.run(table.create { t in - t.column(columnId, primaryKey: .autoincrement) - t.column(columnUUID) - t.column(columnOwnerId, references: Table("workouts"), Expression("data_id")) - t.column(columnIsPrimaryActivity) - t.column(columnActivityType) - t.column(columnLocationType) - t.column(columnSwimmingLocationType) - t.column(columnLapLength) - t.column(columnStartDate) - t.column(columnEndDate) - t.column(columnDuration) - t.column(columnMetadata) - }) - } - - static func insert(_ element: HKWorkoutActivity, isPrimaryActivity: Bool, dataId: Int, in database: Connection) throws { - try database.run(table.insert( - columnUUID <- (element.externalUUID ?? element.uuid).uuidString.data(using: .utf8)!, - columnOwnerId <- dataId, - columnIsPrimaryActivity <- isPrimaryActivity, // Seems to always be 1 - columnActivityType <- Int(element.workoutConfiguration.activityType.rawValue), - columnLocationType <- element.workoutConfiguration.locationType.rawValue, - columnSwimmingLocationType <- element.workoutConfiguration.swimmingLocationType.rawValue, - columnLapLength <- try lapLengthData(lapLength: element.workoutConfiguration.lapLength), - columnStartDate <- element.startDate.timeIntervalSinceReferenceDate, - columnEndDate <- element.endDate?.timeIntervalSinceReferenceDate ?? element.startDate.addingTimeInterval(element.duration).timeIntervalSinceReferenceDate, - columnDuration <- element.duration) - //columnMetadata <- element.metadata) - ) - } -} - -private extension HKWorkoutActivity { - - var externalUUID: UUID? { - guard let string = metadata?[HKMetadataKeyExternalUUID] as? String else { - return nil - } - return UUID(uuidString: string) - } -} - -private extension WorkoutActivityTable { - - static func lapLengthData(lapLength: HKQuantity?) throws -> Data? { - try lapLength.map { try NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: false) } - } - - static func lapLength(from data: Data) throws -> HKQuantity? { - try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQuantity.self, from: data) - } -}