Apple Fitness workout fixer + Strava uploader
at main 537 lines 21 kB view raw
1// 2// StravaManager.swift 3// WorkoutEditor 4// 5 6import CoreLocation 7import Foundation 8import HealthKit 9import UIKit 10 11// MARK: - Configuration 12 13enum StravaConfig { 14 static let clientID = Bundle.main.object(forInfoDictionaryKey: "StravaClientID") as? String ?? "" 15 static let clientSecret = Bundle.main.object(forInfoDictionaryKey: "StravaClientSecret") as? String ?? "" 16 static let redirectURI = "overrun://localhost" 17 static let authorizeURL = "https://www.strava.com/oauth/mobile/authorize" 18 static let tokenURL = "https://www.strava.com/oauth/token" 19 static let uploadURL = "https://www.strava.com/api/v3/uploads" 20} 21 22// MARK: - Keychain Helper 23 24private enum KeychainHelper { 25 static func save(_ data: Data, forKey key: String) { 26 let query: [String: Any] = [ 27 kSecClass as String: kSecClassGenericPassword, 28 kSecAttrAccount as String: key, 29 kSecAttrService as String: "com.overrun.strava" 30 ] 31 SecItemDelete(query as CFDictionary) 32 var addQuery = query 33 addQuery[kSecValueData as String] = data 34 SecItemAdd(addQuery as CFDictionary, nil) 35 } 36 37 static func load(forKey key: String) -> Data? { 38 let query: [String: Any] = [ 39 kSecClass as String: kSecClassGenericPassword, 40 kSecAttrAccount as String: key, 41 kSecAttrService as String: "com.overrun.strava", 42 kSecReturnData as String: true, 43 kSecMatchLimit as String: kSecMatchLimitOne 44 ] 45 var result: AnyObject? 46 SecItemCopyMatching(query as CFDictionary, &result) 47 return result as? Data 48 } 49 50 static func delete(forKey key: String) { 51 let query: [String: Any] = [ 52 kSecClass as String: kSecClassGenericPassword, 53 kSecAttrAccount as String: key, 54 kSecAttrService as String: "com.overrun.strava" 55 ] 56 SecItemDelete(query as CFDictionary) 57 } 58} 59 60// MARK: - Token Storage 61 62private struct StravaTokens: Codable { 63 let accessToken: String 64 let refreshToken: String 65 let expiresAt: Int 66} 67 68// MARK: - Upload Status 69 70enum StravaUploadStatus: Equatable { 71 case idle 72 case uploading 73 case processing 74 case success(activityID: Int64) 75 case error(String) 76} 77 78// MARK: - StravaManager 79 80@MainActor 81class StravaManager: ObservableObject { 82 static let shared = StravaManager() 83 84 @Published var isAuthenticated = false 85 @Published var uploadStatus: StravaUploadStatus = .idle 86 87 private let keychainKey = "strava_tokens" 88 89 init() { 90 isAuthenticated = loadTokens() != nil 91 } 92 93 // MARK: - OAuth 94 95 func authenticate() { 96 var components = URLComponents(string: StravaConfig.authorizeURL)! 97 components.queryItems = [ 98 URLQueryItem(name: "client_id", value: StravaConfig.clientID), 99 URLQueryItem(name: "redirect_uri", value: StravaConfig.redirectURI), 100 URLQueryItem(name: "response_type", value: "code"), 101 URLQueryItem(name: "approval_prompt", value: "auto"), 102 URLQueryItem(name: "scope", value: "activity:write") 103 ] 104 105 guard let url = components.url else { return } 106 107 // Open in Strava app if installed, otherwise falls back to Safari 108 UIApplication.shared.open(url) 109 } 110 111 func handleCallback(_ url: URL) { 112 guard let code = URLComponents(url: url, resolvingAgainstBaseURL: false)? 113 .queryItems?.first(where: { $0.name == "code" })?.value else { return } 114 Task { await exchangeToken(code: code) } 115 } 116 117 private func exchangeToken(code: String) async { 118 guard let url = URL(string: StravaConfig.tokenURL) else { return } 119 120 var request = URLRequest(url: url) 121 request.httpMethod = "POST" 122 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 123 124 let body: [String: String] = [ 125 "client_id": StravaConfig.clientID, 126 "client_secret": StravaConfig.clientSecret, 127 "code": code, 128 "grant_type": "authorization_code" 129 ] 130 request.httpBody = try? JSONSerialization.data(withJSONObject: body) 131 132 do { 133 let (data, _) = try await URLSession.shared.data(for: request) 134 try saveTokensFromResponse(data) 135 isAuthenticated = true 136 } catch { 137 print("Token exchange failed: \(error)") 138 } 139 140 } 141 142 private func refreshAccessToken() async -> Bool { 143 guard let tokens = loadTokens() else { return false } 144 guard let url = URL(string: StravaConfig.tokenURL) else { return false } 145 146 var request = URLRequest(url: url) 147 request.httpMethod = "POST" 148 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 149 150 let body: [String: String] = [ 151 "client_id": StravaConfig.clientID, 152 "client_secret": StravaConfig.clientSecret, 153 "refresh_token": tokens.refreshToken, 154 "grant_type": "refresh_token" 155 ] 156 request.httpBody = try? JSONSerialization.data(withJSONObject: body) 157 158 do { 159 let (data, _) = try await URLSession.shared.data(for: request) 160 try saveTokensFromResponse(data) 161 return true 162 } catch { 163 print("Token refresh failed: \(error)") 164 return false 165 } 166 } 167 168 private func getValidAccessToken() async -> String? { 169 guard let tokens = loadTokens() else { return nil } 170 if Int(Date().timeIntervalSince1970) < tokens.expiresAt - 60 { 171 return tokens.accessToken 172 } 173 if await refreshAccessToken() { 174 return loadTokens()?.accessToken 175 } 176 return nil 177 } 178 179 func disconnect() { 180 KeychainHelper.delete(forKey: keychainKey) 181 isAuthenticated = false 182 uploadStatus = .idle 183 } 184 185 // MARK: - Token Persistence 186 187 private func saveTokensFromResponse(_ data: Data) throws { 188 let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] 189 guard let accessToken = json["access_token"] as? String, 190 let refreshToken = json["refresh_token"] as? String, 191 let expiresAt = json["expires_at"] as? Int else { 192 throw URLError(.cannotParseResponse) 193 } 194 let tokens = StravaTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt) 195 let encoded = try JSONEncoder().encode(tokens) 196 KeychainHelper.save(encoded, forKey: keychainKey) 197 } 198 199 private func loadTokens() -> StravaTokens? { 200 guard let data = KeychainHelper.load(forKey: keychainKey) else { return nil } 201 return try? JSONDecoder().decode(StravaTokens.self, from: data) 202 } 203 204 // MARK: - Upload 205 206 func uploadWorkout(_ workout: HKWorkout) async { 207 uploadStatus = .uploading 208 209 guard let accessToken = await getValidAccessToken() else { 210 uploadStatus = .error("Not authenticated. Please reconnect Strava.") 211 isAuthenticated = false 212 return 213 } 214 215 // Query HealthKit for heart rate and route data 216 let heartRateSamples = await fetchHeartRateSamples(for: workout) 217 let routeLocations = await fetchRouteLocations(for: workout) 218 219 let tcxData = generateTCX( 220 workout: workout, 221 heartRateSamples: heartRateSamples, 222 locations: routeLocations 223 ) 224 225 guard let url = URL(string: StravaConfig.uploadURL) else { 226 uploadStatus = .error("Invalid upload URL") 227 return 228 } 229 230 let boundary = UUID().uuidString 231 var request = URLRequest(url: url) 232 request.httpMethod = "POST" 233 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 234 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 235 236 let activityType = stravaActivityType(for: workout.workoutActivityType) 237 238 var body = Data() 239 // data_type field 240 body.appendMultipartField(name: "data_type", value: "tcx", boundary: boundary) 241 // activity_type field 242 body.appendMultipartField(name: "activity_type", value: activityType, boundary: boundary) 243 // file field 244 body.appendMultipartFile(name: "file", filename: "workout.tcx", mimeType: "application/xml", data: tcxData, boundary: boundary) 245 body.append("--\(boundary)--\r\n".data(using: .utf8)!) 246 247 request.httpBody = body 248 249 do { 250 let (data, response) = try await URLSession.shared.data(for: request) 251 guard let httpResponse = response as? HTTPURLResponse else { 252 uploadStatus = .error("Invalid response") 253 return 254 } 255 256 if httpResponse.statusCode == 201 { 257 // Upload accepted poll for processing status 258 if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 259 let uploadID = json["id"] as? Int64 { 260 await pollUploadStatus(uploadID: uploadID, accessToken: accessToken) 261 } else { 262 uploadStatus = .success(activityID: 0) 263 } 264 } else if httpResponse.statusCode == 401 { 265 // Token might be expired, try refresh once 266 if await refreshAccessToken(), let newToken = loadTokens()?.accessToken { 267 request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization") 268 let (retryData, retryResp) = try await URLSession.shared.data(for: request) 269 if let retryHttp = retryResp as? HTTPURLResponse, retryHttp.statusCode == 201, 270 let json = try? JSONSerialization.jsonObject(with: retryData) as? [String: Any], 271 let uploadID = json["id"] as? Int64 { 272 await pollUploadStatus(uploadID: uploadID, accessToken: newToken) 273 } else { 274 uploadStatus = .error("Upload failed after token refresh") 275 } 276 } else { 277 uploadStatus = .error("Authentication expired. Please reconnect Strava.") 278 isAuthenticated = false 279 } 280 } else { 281 let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String 282 ?? "Upload failed (HTTP \(httpResponse.statusCode))" 283 uploadStatus = .error(message) 284 } 285 } catch { 286 uploadStatus = .error(error.localizedDescription) 287 } 288 } 289 290 private func pollUploadStatus(uploadID: Int64, accessToken: String) async { 291 uploadStatus = .processing 292 let checkURL = URL(string: "\(StravaConfig.uploadURL)/\(uploadID)")! 293 var request = URLRequest(url: checkURL) 294 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 295 296 for _ in 0..<10 { 297 try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds 298 do { 299 let (data, _) = try await URLSession.shared.data(for: request) 300 if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { 301 if let activityID = json["activity_id"] as? Int64, activityID != 0 { 302 uploadStatus = .success(activityID: activityID) 303 return 304 } 305 if let errorStr = json["error"] as? String, !errorStr.isEmpty { 306 uploadStatus = .error(errorStr) 307 return 308 } 309 // status is still "Your activity is still being processed." 310 } 311 } catch { 312 // Keep polling 313 } 314 } 315 // If we get here, assume success Strava may still be processing 316 uploadStatus = .success(activityID: 0) 317 } 318 319 // MARK: - HealthKit Queries 320 321 private func fetchHeartRateSamples(for workout: HKWorkout) async -> [HKQuantitySample] { 322 let healthStore = HKHealthStore() 323 let hrType = HKQuantityType(.heartRate) 324 let predicate = HKQuery.predicateForSamples( 325 withStart: workout.startDate, 326 end: workout.endDate, 327 options: .strictStartDate 328 ) 329 return await withCheckedContinuation { continuation in 330 let query = HKSampleQuery( 331 sampleType: hrType, 332 predicate: predicate, 333 limit: HKObjectQueryNoLimit, 334 sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] 335 ) { _, samples, _ in 336 continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) 337 } 338 healthStore.execute(query) 339 } 340 } 341 342 private func fetchRouteLocations(for workout: HKWorkout) async -> [CLLocation] { 343 let healthStore = HKHealthStore() 344 let routeType = HKSeriesType.workoutRoute() 345 let predicate = HKQuery.predicateForObjects(from: workout) 346 347 let routes: [HKWorkoutRoute] = await withCheckedContinuation { continuation in 348 let query = HKSampleQuery( 349 sampleType: routeType, 350 predicate: predicate, 351 limit: HKObjectQueryNoLimit, 352 sortDescriptors: nil 353 ) { _, samples, _ in 354 continuation.resume(returning: (samples as? [HKWorkoutRoute]) ?? []) 355 } 356 healthStore.execute(query) 357 } 358 359 guard let route = routes.first else { return [] } 360 361 return await withCheckedContinuation { continuation in 362 var allLocations: [CLLocation] = [] 363 let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in 364 if let locations { allLocations.append(contentsOf: locations) } 365 if done { continuation.resume(returning: allLocations) } 366 } 367 healthStore.execute(query) 368 } 369 } 370 371 // MARK: - TCX Generation 372 373 private func generateTCX( 374 workout: HKWorkout, 375 heartRateSamples: [HKQuantitySample], 376 locations: [CLLocation] 377 ) -> Data { 378 let isoFormatter = ISO8601DateFormatter() 379 isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 380 381 let activitySport = tcxSportName(for: workout.workoutActivityType) 382 let startTime = isoFormatter.string(from: workout.startDate) 383 384 var xml = """ 385 <?xml version="1.0" encoding="UTF-8"?> 386 <TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenter/v2" 387 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 388 xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenter/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd"> 389 <Activities> 390 <Activity Sport="\(activitySport)"> 391 <Id>\(startTime)</Id> 392 <Lap StartTime="\(startTime)"> 393 <TotalTimeSeconds>\(workout.duration)</TotalTimeSeconds> 394 395 """ 396 397 if let distanceStats = workout.statistics(for: HKQuantityType(.distanceWalkingRunning)), 398 let distance = distanceStats.sumQuantity() { 399 xml += " <DistanceMeters>\(distance.doubleValue(for: .meter()))</DistanceMeters>\n" 400 } else { 401 xml += " <DistanceMeters>0</DistanceMeters>\n" 402 } 403 404 if let energyStats = workout.statistics(for: HKQuantityType(.activeEnergyBurned)), 405 let calories = energyStats.sumQuantity() { 406 xml += " <Calories>\(Int(calories.doubleValue(for: .kilocalorie())))</Calories>\n" 407 } else { 408 xml += " <Calories>0</Calories>\n" 409 } 410 411 xml += " <Intensity>Active</Intensity>\n" 412 xml += " <TriggerMethod>Manual</TriggerMethod>\n" 413 xml += " <Track>\n" 414 415 // Build a timeline of trackpoints 416 // Merge heart rate samples and GPS locations by time 417 var trackpoints: [(date: Date, hr: Double?, location: CLLocation?)] = [] 418 419 // Add GPS points 420 for loc in locations { 421 trackpoints.append((date: loc.timestamp, hr: nil, location: loc)) 422 } 423 424 // Add heart rate points 425 let bpmUnit = HKUnit.count().unitDivided(by: .minute()) 426 for sample in heartRateSamples { 427 let bpm = sample.quantity.doubleValue(for: bpmUnit) 428 trackpoints.append((date: sample.startDate, hr: bpm, location: nil)) 429 } 430 431 // If no trackpoints at all, add start and end markers 432 if trackpoints.isEmpty { 433 trackpoints.append((date: workout.startDate, hr: nil, location: nil)) 434 trackpoints.append((date: workout.endDate, hr: nil, location: nil)) 435 } 436 437 // Sort by time 438 trackpoints.sort { $0.date < $1.date } 439 440 // Merge nearby trackpoints (within 2 seconds) 441 var merged: [(date: Date, hr: Double?, location: CLLocation?)] = [] 442 for tp in trackpoints { 443 if let lastIdx = merged.indices.last, 444 abs(merged[lastIdx].date.timeIntervalSince(tp.date)) < 2.0 { 445 // Merge into existing 446 if tp.hr != nil { merged[lastIdx].hr = tp.hr } 447 if tp.location != nil { merged[lastIdx].location = tp.location } 448 } else { 449 merged.append(tp) 450 } 451 } 452 453 // Calculate cumulative distance from GPS 454 var cumulativeDistance: Double = 0 455 var lastLocation: CLLocation? 456 for tp in merged { 457 let time = isoFormatter.string(from: tp.date) 458 xml += " <Trackpoint>\n" 459 xml += " <Time>\(time)</Time>\n" 460 461 if let loc = tp.location { 462 if let prev = lastLocation { 463 cumulativeDistance += loc.distance(from: prev) 464 } 465 lastLocation = loc 466 xml += " <Position>\n" 467 xml += " <LatitudeDegrees>\(loc.coordinate.latitude)</LatitudeDegrees>\n" 468 xml += " <LongitudeDegrees>\(loc.coordinate.longitude)</LongitudeDegrees>\n" 469 xml += " </Position>\n" 470 xml += " <AltitudeMeters>\(loc.altitude)</AltitudeMeters>\n" 471 xml += " <DistanceMeters>\(cumulativeDistance)</DistanceMeters>\n" 472 } 473 474 if let hr = tp.hr { 475 xml += " <HeartRateBpm>\n" 476 xml += " <Value>\(Int(hr))</Value>\n" 477 xml += " </HeartRateBpm>\n" 478 } 479 480 xml += " </Trackpoint>\n" 481 } 482 483 xml += """ 484 </Track> 485 </Lap> 486 </Activity> 487 </Activities> 488 </TrainingCenterDatabase> 489 """ 490 491 return Data(xml.utf8) 492 } 493 494 // MARK: - Activity Type Mapping 495 496 private func stravaActivityType(for hkType: HKWorkoutActivityType) -> String { 497 switch hkType { 498 case .running: return "run" 499 case .cycling: return "ride" 500 case .swimming: return "swim" 501 case .walking: return "walk" 502 case .hiking: return "hike" 503 case .crossTraining: return "crossfit" 504 case .yoga: return "yoga" 505 case .rowing: return "rowing" 506 case .elliptical: return "elliptical" 507 default: return "workout" 508 } 509 } 510 511 private func tcxSportName(for hkType: HKWorkoutActivityType) -> String { 512 switch hkType { 513 case .running: return "Running" 514 case .cycling: return "Biking" 515 default: return "Other" 516 } 517 } 518} 519 520// MARK: - Multipart Helpers 521 522private extension Data { 523 mutating func appendMultipartField(name: String, value: String, boundary: String) { 524 append("--\(boundary)\r\n".data(using: .utf8)!) 525 append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) 526 append("\(value)\r\n".data(using: .utf8)!) 527 } 528 529 mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { 530 append("--\(boundary)\r\n".data(using: .utf8)!) 531 append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) 532 append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) 533 append(data) 534 append("\r\n".data(using: .utf8)!) 535 } 536} 537