Apple Fitness workout fixer + Strava uploader
at main 507 lines 20 kB view raw
1import CoreLocation 2import Foundation 3import HealthKit 4 5struct IntensitySample: Identifiable { 6 let id = UUID() 7 let date: Date 8 let value: Double 9} 10 11enum IntensityMetric { 12 case heartRate 13 case activeEnergy 14 case distance 15 16 var unit: HKUnit { 17 switch self { 18 case .heartRate: .count().unitDivided(by: .minute()) 19 case .activeEnergy: .kilocalorie() 20 case .distance: .meter() 21 } 22 } 23 24 var label: String { 25 switch self { 26 case .heartRate: "BPM" 27 case .activeEnergy: "kcal" 28 case .distance: "m" 29 } 30 } 31 32 var quantityType: HKQuantityType { 33 switch self { 34 case .heartRate: HKQuantityType(.heartRate) 35 case .activeEnergy: HKQuantityType(.activeEnergyBurned) 36 case .distance: HKQuantityType(.distanceWalkingRunning) 37 } 38 } 39} 40 41@Observable 42class HealthKitManager { 43 static let shared = HealthKitManager() 44 45 var loadedWorkouts: [HKWorkout] = [] 46 var canLoadMore = false 47 private let healthStore = HKHealthStore() 48 private let pageSize = 50 49 private var observerQuery: HKObserverQuery? 50 51 func startObservingWorkouts() { 52 guard observerQuery == nil else { return } 53 let query = HKObserverQuery(sampleType: HKObjectType.workoutType(), predicate: nil) { [weak self] _, _, _ in 54 guard let self else { return } 55 Task { await self.loadWorkouts() } 56 } 57 healthStore.execute(query) 58 observerQuery = query 59 } 60 61 func requestAuthorization() async throws { 62 guard HKHealthStore.isHealthDataAvailable() else { 63 throw HealthKitError.healthDataNotAvailable 64 } 65 66 var sampleTypes: Set<HKSampleType> = [ 67 HKObjectType.workoutType(), 68 HKQuantityType(.heartRate), 69 HKQuantityType(.activeEnergyBurned), 70 HKQuantityType(.basalEnergyBurned), 71 HKQuantityType(.distanceWalkingRunning), 72 HKQuantityType(.distanceCycling), 73 HKQuantityType(.stepCount), 74 HKQuantityType(.runningSpeed), 75 HKQuantityType(.runningPower), 76 HKQuantityType(.runningStrideLength), 77 HKQuantityType(.runningVerticalOscillation), 78 HKQuantityType(.runningGroundContactTime), 79 HKQuantityType(.vo2Max), 80 HKQuantityType(.physicalEffort), 81 HKSeriesType.workoutRoute(), 82 ] 83 if #available(iOS 18.0, *) { 84 sampleTypes.insert(HKQuantityType(.workoutEffortScore)) 85 sampleTypes.insert(HKQuantityType(.estimatedWorkoutEffortScore)) 86 } 87 88 let typesToRead: Set<HKObjectType> = sampleTypes as Set<HKObjectType> 89 let typesToWrite: Set<HKSampleType> = sampleTypes 90 91 try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) 92 } 93 94 func loadWorkouts() async { 95 let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) 96 let descriptor = HKSampleQueryDescriptor( 97 predicates: [.workout()], 98 sortDescriptors: [sortDescriptor], 99 limit: pageSize 100 ) 101 102 do { 103 let results = try await descriptor.result(for: healthStore) 104 await MainActor.run { 105 self.loadedWorkouts = results 106 self.canLoadMore = results.count == pageSize 107 } 108 } catch { 109 print("Error loading workouts: \(error.localizedDescription)") 110 } 111 } 112 113 func loadMoreWorkouts() async { 114 guard canLoadMore, let oldest = loadedWorkouts.last else { return } 115 116 let predicate = HKQuery.predicateForSamples( 117 withStart: nil, 118 end: oldest.startDate, 119 options: .strictEndDate 120 ) 121 let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) 122 let descriptor = HKSampleQueryDescriptor( 123 predicates: [.workout(predicate)], 124 sortDescriptors: [sortDescriptor], 125 limit: pageSize 126 ) 127 128 do { 129 let results = try await descriptor.result(for: healthStore) 130 await MainActor.run { 131 self.loadedWorkouts.append(contentsOf: results) 132 self.canLoadMore = results.count == pageSize 133 } 134 } catch { 135 print("Error loading more workouts: \(error.localizedDescription)") 136 } 137 } 138 139 func fetchIntensitySamples(for workout: HKWorkout) async -> (samples: [IntensitySample], metric: IntensityMetric) { 140 let metrics: [IntensityMetric] = [.heartRate, .activeEnergy, .distance] 141 142 for metric in metrics { 143 let predicate = HKQuery.predicateForSamples( 144 withStart: workout.startDate, 145 end: workout.endDate, 146 options: .strictStartDate 147 ) 148 let sortDescriptor = SortDescriptor(\HKQuantitySample.startDate, order: .forward) 149 let descriptor = HKSampleQueryDescriptor( 150 predicates: [.quantitySample(type: metric.quantityType, predicate: predicate)], 151 sortDescriptors: [sortDescriptor], 152 limit: 5000 153 ) 154 155 do { 156 let results = try await descriptor.result(for: healthStore) 157 if !results.isEmpty { 158 var samples = results.map { sample in 159 IntensitySample( 160 date: sample.startDate, 161 value: sample.quantity.doubleValue(for: metric.unit) 162 ) 163 } 164 if samples.count > 500 { 165 samples = downsample(samples, to: 500) 166 } 167 return (samples, metric) 168 } 169 } catch { 170 print("Error fetching \(metric.label): \(error.localizedDescription)") 171 } 172 } 173 174 return ([], .heartRate) 175 } 176 177 private func downsample(_ samples: [IntensitySample], to target: Int) -> [IntensitySample] { 178 let stride = Double(samples.count) / Double(target) 179 return (0..<target).map { i in 180 samples[min(Int(Double(i) * stride), samples.count - 1)] 181 } 182 } 183 184 private static var migratedSampleTypes: [HKQuantityType] { 185 var types: [HKQuantityType] = [ 186 HKQuantityType(.heartRate), 187 HKQuantityType(.activeEnergyBurned), 188 HKQuantityType(.basalEnergyBurned), 189 HKQuantityType(.distanceWalkingRunning), 190 HKQuantityType(.distanceCycling), 191 HKQuantityType(.stepCount), 192 HKQuantityType(.runningSpeed), 193 HKQuantityType(.runningPower), 194 HKQuantityType(.runningStrideLength), 195 HKQuantityType(.runningVerticalOscillation), 196 HKQuantityType(.runningGroundContactTime), 197 HKQuantityType(.vo2Max), 198 HKQuantityType(.physicalEffort), 199 ] 200 if #available(iOS 18.0, *) { 201 types.append(HKQuantityType(.workoutEffortScore)) 202 types.append(HKQuantityType(.estimatedWorkoutEffortScore)) 203 } 204 return types 205 } 206 207 func fetchAssociatedSamples(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [HKQuantitySample] { 208 var allSamples: [HKQuantitySample] = [] 209 let workoutPredicate = HKQuery.predicateForObjects(from: workout) 210 211 for sampleType in Self.migratedSampleTypes { 212 let descriptor = HKSampleQueryDescriptor( 213 predicates: [.quantitySample(type: sampleType, predicate: workoutPredicate)], 214 sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)] 215 ) 216 do { 217 let results = try await descriptor.result(for: healthStore) 218 let filtered = results.filter { range.contains($0.startDate) } 219 allSamples.append(contentsOf: filtered) 220 } catch { 221 print("Error fetching \(sampleType): \(error.localizedDescription)") 222 } 223 } 224 return allSamples 225 } 226 227 func fetchRouteLocations(for workout: HKWorkout, in range: ClosedRange<Date>) async -> [CLLocation] { 228 let workoutPredicate = HKQuery.predicateForObjects(from: workout) 229 let routeType = HKSeriesType.workoutRoute() 230 let descriptor = HKSampleQueryDescriptor( 231 predicates: [.sample(type: routeType, predicate: workoutPredicate)], 232 sortDescriptors: [SortDescriptor(\HKWorkoutRoute.startDate, order: .forward)] 233 ) 234 235 var allLocations: [CLLocation] = [] 236 do { 237 let results = try await descriptor.result(for: healthStore) 238 let routes = results.compactMap { $0 as? HKWorkoutRoute } 239 for route in routes { 240 let locations = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CLLocation], Error>) in 241 var accumulated: [CLLocation] = [] 242 let query = HKWorkoutRouteQuery(route: route) { _, locations, done, error in 243 if let error { 244 continuation.resume(throwing: error) 245 return 246 } 247 if let locations { accumulated.append(contentsOf: locations) } 248 if done { continuation.resume(returning: accumulated) } 249 } 250 self.healthStore.execute(query) 251 } 252 let filtered = locations.filter { range.contains($0.timestamp) } 253 allLocations.append(contentsOf: filtered) 254 } 255 } catch { 256 print("Error fetching route locations: \(error.localizedDescription)") 257 } 258 return allLocations 259 } 260 261 /// Fetch user-entered workout effort score by time range (not associated via predicateForObjects). 262 /// estimatedWorkoutEffortScore is Apple-computed and cannot be written by third-party apps. 263 @available(iOS 18.0, *) 264 private func fetchWorkoutEffortSample(in range: ClosedRange<Date>) async -> HKQuantitySample? { 265 let timePredicate = HKQuery.predicateForSamples( 266 withStart: range.lowerBound, 267 end: range.upperBound, 268 options: .strictStartDate 269 ) 270 let effortType = HKQuantityType(.workoutEffortScore) 271 let descriptor = HKSampleQueryDescriptor( 272 predicates: [.quantitySample(type: effortType, predicate: timePredicate)], 273 sortDescriptors: [SortDescriptor(\HKQuantitySample.startDate, order: .forward)], 274 limit: 1 275 ) 276 do { 277 let samples = try await descriptor.result(for: healthStore) 278 print("[Trim] workoutEffortScore query: \(samples.count) samples") 279 return samples.first 280 } catch { 281 print("[Trim] Error fetching workoutEffortScore: \(error.localizedDescription)") 282 return nil 283 } 284 } 285 286 struct TrimResult { 287 let trimmedWorkout: HKWorkout 288 let originalDeleted: Bool 289 let originalSource: String 290 } 291 292 func saveTrimmedWorkout( 293 original: HKWorkout, 294 activityType: HKWorkoutActivityType, 295 start: Date, 296 end: Date 297 ) async throws -> TrimResult { 298 let range = start...end 299 let originalSource = original.sourceRevision.source.name 300 301 // Fetch associated data 302 async let samplesTask = fetchAssociatedSamples(for: original, in: range) 303 async let locationsTask = fetchRouteLocations(for: original, in: range) 304 let (samples, locations) = await (samplesTask, locationsTask) 305 306 // Filter workout events to trimmed range 307 let filteredEvents = original.workoutEvents?.filter { event in 308 range.contains(event.dateInterval.start) 309 } ?? [] 310 311 let metadata = original.metadata 312 313 // Build new workout, preserving location type from original 314 let configuration = HKWorkoutConfiguration() 315 configuration.activityType = activityType 316 if let originalConfig = original.workoutActivities.first?.workoutConfiguration { 317 configuration.locationType = originalConfig.locationType 318 configuration.swimmingLocationType = originalConfig.swimmingLocationType 319 configuration.lapLength = originalConfig.lapLength 320 } else if let isIndoor = metadata?[HKMetadataKeyIndoorWorkout] as? Bool { 321 configuration.locationType = isIndoor ? .indoor : .outdoor 322 } 323 324 print("[Trim] Activity: \(activityType.rawValue), locationType: \(configuration.locationType.rawValue)") 325 print("[Trim] Samples fetched: \(samples.count)") 326 let samplesByType = Dictionary(grouping: samples, by: { $0.quantityType.identifier }) 327 for (type, typeSamples) in samplesByType { 328 print("[Trim] \(type): \(typeSamples.count) samples") 329 } 330 print("[Trim] Route locations: \(locations.count)") 331 print("[Trim] Events: \(filteredEvents.count)") 332 print("[Trim] Metadata keys: \(metadata?.keys.joined(separator: ", ") ?? "none")") 333 334 let builder = HKWorkoutBuilder( 335 healthStore: healthStore, 336 configuration: configuration, 337 device: original.device 338 ) 339 340 try await builder.beginCollection(at: start) 341 342 // Add workout activities first so builder can associate samples with them 343 for activity in original.workoutActivities { 344 let activityStart = max(activity.startDate, start) 345 let activityEnd = min(activity.endDate ?? end, end) 346 guard activityStart < activityEnd else { continue } 347 do { 348 let newActivity = HKWorkoutActivity( 349 workoutConfiguration: activity.workoutConfiguration, 350 start: activityStart, 351 end: activityEnd, 352 metadata: activity.metadata 353 ) 354 try await builder.addWorkoutActivity(newActivity) 355 print("[Trim] Added workout activity: \(activity.workoutConfiguration.activityType.rawValue)") 356 } catch { 357 print("[Trim] Could not copy workout activity: \(error.localizedDescription)") 358 } 359 } 360 361 // Recreate samples with new objects 362 if !samples.isEmpty { 363 let newSamples: [HKQuantitySample] = samples.map { s in 364 HKQuantitySample( 365 type: s.quantityType, 366 quantity: s.quantity, 367 start: s.startDate, 368 end: s.endDate, 369 device: s.device, 370 metadata: s.metadata 371 ) 372 } 373 try await builder.addSamples(newSamples) 374 } 375 376 if !filteredEvents.isEmpty { 377 try await builder.addWorkoutEvents(filteredEvents) 378 } 379 380 if let metadata, !metadata.isEmpty { 381 try await builder.addMetadata(metadata) 382 } 383 384 try await builder.endCollection(at: end) 385 guard let newWorkout = try await builder.finishWorkout() else { 386 throw HealthKitError.workoutCreationFailed 387 } 388 389 // Build route if we have locations 390 if !locations.isEmpty { 391 let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: original.device) 392 try await routeBuilder.insertRouteData(locations) 393 try await routeBuilder.finishRoute(with: newWorkout, metadata: nil) 394 } 395 396 // Relate user-entered effort score via special API (iOS 18+) 397 if #available(iOS 18.0, *) { 398 if let originalEffort = await fetchWorkoutEffortSample(in: range) { 399 let newEffort = HKQuantitySample( 400 type: originalEffort.quantityType, 401 quantity: originalEffort.quantity, 402 start: originalEffort.startDate, 403 end: originalEffort.endDate, 404 device: originalEffort.device, 405 metadata: originalEffort.metadata 406 ) 407 do { 408 try await healthStore.relateWorkoutEffortSample(newEffort, with: newWorkout, activity: nil) 409 print("[Trim] Related workoutEffortScore to new workout") 410 } catch { 411 print("[Trim] Error relating effort: \(error.localizedDescription)") 412 } 413 } 414 } 415 416 // Try to delete original may fail for workouts from other apps 417 var deleted = false 418 do { 419 try await healthStore.delete(original) 420 deleted = true 421 } catch { 422 print("Could not delete original workout: \(error.localizedDescription)") 423 } 424 425 return TrimResult(trimmedWorkout: newWorkout, originalDeleted: deleted, originalSource: originalSource) 426 } 427 428 func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { 429 let configuration = HKWorkoutConfiguration() 430 configuration.activityType = activityType 431 432 let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) 433 try await builder.beginCollection(at: start) 434 try await builder.endCollection(at: end) 435 436 if let metadata = metadata { 437 try await builder.addMetadata(metadata) 438 } 439 440 try await builder.finishWorkout() 441 } 442 443 func deleteWorkout(_ workout: HKWorkout) async throws { 444 try await healthStore.delete(workout) 445 } 446 447 #if DEBUG 448 func insertSampleWorkouts() async throws { 449 let activityTypes: [HKWorkoutActivityType] = [ 450 .running, .cycling, .swimming, .hiking, .walking, 451 .yoga, .functionalStrengthTraining, .coreTraining, 452 .elliptical, .rowing, 453 ] 454 let durations: [TimeInterval] = [ 455 20 * 60, 30 * 60, 45 * 60, 60 * 60, 75 * 60, 90 * 60, 120 * 60, 456 ] 457 458 // Generate 200 workouts spread over the past ~12 months 459 let now = Date() 460 let calendar = Calendar.current 461 for i in 0..<200 { 462 let daysAgo = Int(Double(i) * 1.8) + Int.random(in: 0...1) 463 let hour = [6, 7, 8, 12, 17, 18, 19][i % 7] 464 guard let day = calendar.date(byAdding: .day, value: -daysAgo, to: now), 465 let start = calendar.date(bySettingHour: hour, minute: Int.random(in: 0...59), second: 0, of: day) 466 else { continue } 467 468 let activityType = activityTypes[i % activityTypes.count] 469 let duration = durations[i % durations.count] 470 let end = start.addingTimeInterval(duration) 471 472 let configuration = HKWorkoutConfiguration() 473 configuration.activityType = activityType 474 475 let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) 476 try await builder.beginCollection(at: start) 477 try await builder.endCollection(at: end) 478 try await builder.finishWorkout() 479 } 480 } 481 482 func deleteAllWorkouts() async throws { 483 let descriptor = HKSampleQueryDescriptor( 484 predicates: [.workout()], 485 sortDescriptors: [SortDescriptor(\HKWorkout.startDate, order: .reverse)] 486 ) 487 let all = try await descriptor.result(for: healthStore) 488 for workout in all { 489 try await healthStore.delete(workout) 490 } 491 } 492 #endif 493} 494 495enum HealthKitError: LocalizedError { 496 case healthDataNotAvailable 497 case workoutCreationFailed 498 499 var errorDescription: String? { 500 switch self { 501 case .healthDataNotAvailable: 502 return "Health data is not available on this device." 503 case .workoutCreationFailed: 504 return "Failed to create the new workout." 505 } 506 } 507}