Apple Fitness workout fixer + Strava uploader
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