// // StravaManager.swift // WorkoutEditor // import CoreLocation import Foundation import HealthKit import UIKit // MARK: - Configuration enum StravaConfig { static let clientID = Bundle.main.object(forInfoDictionaryKey: "StravaClientID") as? String ?? "" static let clientSecret = Bundle.main.object(forInfoDictionaryKey: "StravaClientSecret") as? String ?? "" static let redirectURI = "overrun://localhost" static let authorizeURL = "https://www.strava.com/oauth/mobile/authorize" static let tokenURL = "https://www.strava.com/oauth/token" static let uploadURL = "https://www.strava.com/api/v3/uploads" } // MARK: - Keychain Helper private enum KeychainHelper { static func save(_ data: Data, forKey key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: "com.overrun.strava" ] SecItemDelete(query as CFDictionary) var addQuery = query addQuery[kSecValueData as String] = data SecItemAdd(addQuery as CFDictionary, nil) } static func load(forKey key: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: "com.overrun.strava", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? SecItemCopyMatching(query as CFDictionary, &result) return result as? Data } static func delete(forKey key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: "com.overrun.strava" ] SecItemDelete(query as CFDictionary) } } // MARK: - Token Storage private struct StravaTokens: Codable { let accessToken: String let refreshToken: String let expiresAt: Int } // MARK: - Upload Status enum StravaUploadStatus: Equatable { case idle case uploading case processing case success(activityID: Int64) case error(String) } // MARK: - StravaManager @MainActor class StravaManager: ObservableObject { static let shared = StravaManager() @Published var isAuthenticated = false @Published var uploadStatus: StravaUploadStatus = .idle private let keychainKey = "strava_tokens" init() { isAuthenticated = loadTokens() != nil } // MARK: - OAuth func authenticate() { var components = URLComponents(string: StravaConfig.authorizeURL)! components.queryItems = [ URLQueryItem(name: "client_id", value: StravaConfig.clientID), URLQueryItem(name: "redirect_uri", value: StravaConfig.redirectURI), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "approval_prompt", value: "auto"), URLQueryItem(name: "scope", value: "activity:write") ] guard let url = components.url else { return } // Open in Strava app if installed, otherwise falls back to Safari UIApplication.shared.open(url) } func handleCallback(_ url: URL) { guard let code = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "code" })?.value else { return } Task { await exchangeToken(code: code) } } private func exchangeToken(code: String) async { guard let url = URL(string: StravaConfig.tokenURL) else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ "client_id": StravaConfig.clientID, "client_secret": StravaConfig.clientSecret, "code": code, "grant_type": "authorization_code" ] request.httpBody = try? JSONSerialization.data(withJSONObject: body) do { let (data, _) = try await URLSession.shared.data(for: request) try saveTokensFromResponse(data) isAuthenticated = true } catch { print("Token exchange failed: \(error)") } } private func refreshAccessToken() async -> Bool { guard let tokens = loadTokens() else { return false } guard let url = URL(string: StravaConfig.tokenURL) else { return false } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ "client_id": StravaConfig.clientID, "client_secret": StravaConfig.clientSecret, "refresh_token": tokens.refreshToken, "grant_type": "refresh_token" ] request.httpBody = try? JSONSerialization.data(withJSONObject: body) do { let (data, _) = try await URLSession.shared.data(for: request) try saveTokensFromResponse(data) return true } catch { print("Token refresh failed: \(error)") return false } } private func getValidAccessToken() async -> String? { guard let tokens = loadTokens() else { return nil } if Int(Date().timeIntervalSince1970) < tokens.expiresAt - 60 { return tokens.accessToken } if await refreshAccessToken() { return loadTokens()?.accessToken } return nil } func disconnect() { KeychainHelper.delete(forKey: keychainKey) isAuthenticated = false uploadStatus = .idle } // MARK: - Token Persistence private func saveTokensFromResponse(_ data: Data) throws { let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] guard let accessToken = json["access_token"] as? String, let refreshToken = json["refresh_token"] as? String, let expiresAt = json["expires_at"] as? Int else { throw URLError(.cannotParseResponse) } let tokens = StravaTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt) let encoded = try JSONEncoder().encode(tokens) KeychainHelper.save(encoded, forKey: keychainKey) } private func loadTokens() -> StravaTokens? { guard let data = KeychainHelper.load(forKey: keychainKey) else { return nil } return try? JSONDecoder().decode(StravaTokens.self, from: data) } // MARK: - Upload func uploadWorkout(_ workout: HKWorkout) async { uploadStatus = .uploading guard let accessToken = await getValidAccessToken() else { uploadStatus = .error("Not authenticated. Please reconnect Strava.") isAuthenticated = false return } // Query HealthKit for heart rate and route data let heartRateSamples = await fetchHeartRateSamples(for: workout) let routeLocations = await fetchRouteLocations(for: workout) let tcxData = generateTCX( workout: workout, heartRateSamples: heartRateSamples, locations: routeLocations ) guard let url = URL(string: StravaConfig.uploadURL) else { uploadStatus = .error("Invalid upload URL") return } let boundary = UUID().uuidString var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let activityType = stravaActivityType(for: workout.workoutActivityType) var body = Data() // data_type field body.appendMultipartField(name: "data_type", value: "tcx", boundary: boundary) // activity_type field body.appendMultipartField(name: "activity_type", value: activityType, boundary: boundary) // file field body.appendMultipartFile(name: "file", filename: "workout.tcx", mimeType: "application/xml", data: tcxData, boundary: boundary) body.append("--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { uploadStatus = .error("Invalid response") return } if httpResponse.statusCode == 201 { // Upload accepted — poll for processing status if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let uploadID = json["id"] as? Int64 { await pollUploadStatus(uploadID: uploadID, accessToken: accessToken) } else { uploadStatus = .success(activityID: 0) } } else if httpResponse.statusCode == 401 { // Token might be expired, try refresh once if await refreshAccessToken(), let newToken = loadTokens()?.accessToken { request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization") let (retryData, retryResp) = try await URLSession.shared.data(for: request) if let retryHttp = retryResp as? HTTPURLResponse, retryHttp.statusCode == 201, let json = try? JSONSerialization.jsonObject(with: retryData) as? [String: Any], let uploadID = json["id"] as? Int64 { await pollUploadStatus(uploadID: uploadID, accessToken: newToken) } else { uploadStatus = .error("Upload failed after token refresh") } } else { uploadStatus = .error("Authentication expired. Please reconnect Strava.") isAuthenticated = false } } else { let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String ?? "Upload failed (HTTP \(httpResponse.statusCode))" uploadStatus = .error(message) } } catch { uploadStatus = .error(error.localizedDescription) } } private func pollUploadStatus(uploadID: Int64, accessToken: String) async { uploadStatus = .processing let checkURL = URL(string: "\(StravaConfig.uploadURL)/\(uploadID)")! var request = URLRequest(url: checkURL) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") for _ in 0..<10 { try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds do { let (data, _) = try await URLSession.shared.data(for: request) if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let activityID = json["activity_id"] as? Int64, activityID != 0 { uploadStatus = .success(activityID: activityID) return } if let errorStr = json["error"] as? String, !errorStr.isEmpty { uploadStatus = .error(errorStr) return } // status is still "Your activity is still being processed." } } catch { // Keep polling } } // If we get here, assume success — Strava may still be processing uploadStatus = .success(activityID: 0) } // MARK: - HealthKit Queries private func fetchHeartRateSamples(for workout: HKWorkout) async -> [HKQuantitySample] { let healthStore = HKHealthStore() let hrType = HKQuantityType(.heartRate) let predicate = HKQuery.predicateForSamples( withStart: workout.startDate, end: workout.endDate, options: .strictStartDate ) return await withCheckedContinuation { continuation in let query = HKSampleQuery( sampleType: hrType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] ) { _, samples, _ in continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) } healthStore.execute(query) } } private func fetchRouteLocations(for workout: HKWorkout) async -> [CLLocation] { let healthStore = HKHealthStore() let routeType = HKSeriesType.workoutRoute() let predicate = HKQuery.predicateForObjects(from: workout) let routes: [HKWorkoutRoute] = await withCheckedContinuation { continuation in let query = HKSampleQuery( sampleType: routeType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil ) { _, samples, _ in continuation.resume(returning: (samples as? [HKWorkoutRoute]) ?? []) } healthStore.execute(query) } guard let route = routes.first else { return [] } return await withCheckedContinuation { continuation in var allLocations: [CLLocation] = [] let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in if let locations { allLocations.append(contentsOf: locations) } if done { continuation.resume(returning: allLocations) } } healthStore.execute(query) } } // MARK: - TCX Generation private func generateTCX( workout: HKWorkout, heartRateSamples: [HKQuantitySample], locations: [CLLocation] ) -> Data { let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let activitySport = tcxSportName(for: workout.workoutActivityType) let startTime = isoFormatter.string(from: workout.startDate) var xml = """ \(startTime) \(workout.duration) """ if let distanceStats = workout.statistics(for: HKQuantityType(.distanceWalkingRunning)), let distance = distanceStats.sumQuantity() { xml += " \(distance.doubleValue(for: .meter()))\n" } else { xml += " 0\n" } if let energyStats = workout.statistics(for: HKQuantityType(.activeEnergyBurned)), let calories = energyStats.sumQuantity() { xml += " \(Int(calories.doubleValue(for: .kilocalorie())))\n" } else { xml += " 0\n" } xml += " Active\n" xml += " Manual\n" xml += " \n" // Build a timeline of trackpoints // Merge heart rate samples and GPS locations by time var trackpoints: [(date: Date, hr: Double?, location: CLLocation?)] = [] // Add GPS points for loc in locations { trackpoints.append((date: loc.timestamp, hr: nil, location: loc)) } // Add heart rate points let bpmUnit = HKUnit.count().unitDivided(by: .minute()) for sample in heartRateSamples { let bpm = sample.quantity.doubleValue(for: bpmUnit) trackpoints.append((date: sample.startDate, hr: bpm, location: nil)) } // If no trackpoints at all, add start and end markers if trackpoints.isEmpty { trackpoints.append((date: workout.startDate, hr: nil, location: nil)) trackpoints.append((date: workout.endDate, hr: nil, location: nil)) } // Sort by time trackpoints.sort { $0.date < $1.date } // Merge nearby trackpoints (within 2 seconds) var merged: [(date: Date, hr: Double?, location: CLLocation?)] = [] for tp in trackpoints { if let lastIdx = merged.indices.last, abs(merged[lastIdx].date.timeIntervalSince(tp.date)) < 2.0 { // Merge into existing if tp.hr != nil { merged[lastIdx].hr = tp.hr } if tp.location != nil { merged[lastIdx].location = tp.location } } else { merged.append(tp) } } // Calculate cumulative distance from GPS var cumulativeDistance: Double = 0 var lastLocation: CLLocation? for tp in merged { let time = isoFormatter.string(from: tp.date) xml += " \n" xml += " \n" if let loc = tp.location { if let prev = lastLocation { cumulativeDistance += loc.distance(from: prev) } lastLocation = loc xml += " \n" xml += " \(loc.coordinate.latitude)\n" xml += " \(loc.coordinate.longitude)\n" xml += " \n" xml += " \(loc.altitude)\n" xml += " \(cumulativeDistance)\n" } if let hr = tp.hr { xml += " \n" xml += " \(Int(hr))\n" xml += " \n" } xml += " \n" } xml += """ """ return Data(xml.utf8) } // MARK: - Activity Type Mapping private func stravaActivityType(for hkType: HKWorkoutActivityType) -> String { switch hkType { case .running: return "run" case .cycling: return "ride" case .swimming: return "swim" case .walking: return "walk" case .hiking: return "hike" case .crossTraining: return "crossfit" case .yoga: return "yoga" case .rowing: return "rowing" case .elliptical: return "elliptical" default: return "workout" } } private func tcxSportName(for hkType: HKWorkoutActivityType) -> String { switch hkType { case .running: return "Running" case .cycling: return "Biking" default: return "Other" } } } // MARK: - Multipart Helpers private extension Data { mutating func appendMultipartField(name: String, value: String, boundary: String) { append("--\(boundary)\r\n".data(using: .utf8)!) append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) append("\(value)\r\n".data(using: .utf8)!) } mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { append("--\(boundary)\r\n".data(using: .utf8)!) append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) append(data) append("\r\n".data(using: .utf8)!) } }