From 28c67cba44ae4e01b5f9fb7f46a58e12e15b7269 Mon Sep 17 00:00:00 2001 From: rodrigosa Date: Sat, 19 Sep 2026 14:03:00 +0100 Subject: [PATCH 1/2] Add Apple Health workout route (GPX) sync support. Refs #1314. --trailer "Co-authored-by: Junie " --- StrandiOS/Health/HealthKitBridge.swift | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index bcb3b8bfab..6d5a178e18 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -111,6 +111,7 @@ final class HealthKitBridge: ObservableObject { } if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } s.insert(HKObjectType.workoutType()) + s.insert(HKSeriesType.workoutRoute()) return s } @@ -1242,7 +1243,27 @@ final class HealthKitBridge: ObservableObject { } if !extras.isEmpty { try await builder.addSamples(extras) } try await builder.endCollection(at: end) - _ = try await builder.finishWorkout() + let workout = try await builder.finishWorkout() + + // #1314: workout route (GPX) write-back. Load the encoded polyline from the Apple-only + // side-store and attach it as a separate series to the workout we just finished. + // We only do this if the workout type supports a distance (GPS) route. + if let workout, + Self.distanceTypeId(forSport: row.sport) != nil, + store.authorizationStatus(for: HKSeriesType.workoutRoute()) == .sharingAuthorized, + let route = RouteStore.load(startTs: row.startTs, sport: row.sport), + !route.polyline.isEmpty { + let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local()) + let points = RouteMath.decode(route.polyline) + let locs = points.map { p in + CLLocation(coordinate: CLLocationCoordinate2D(latitude: p.lat, longitude: p.lon), + altitude: 0, horizontalAccuracy: 1, verticalAccuracy: 1, timestamp: start) + } + if !locs.isEmpty { + try await routeBuilder.insertRouteData(locs) + try await routeBuilder.finishRoute(with: workout, metadata: nil) + } + } } catch { builder.discardWorkout() throw error From 152afb88093e00f43a2a4d41fd7c207a8c5c7e27 Mon Sep 17 00:00:00 2001 From: rodrigosa Date: Sun, 20 Sep 2026 09:09:26 +0100 Subject: [PATCH 2/2] HealthKit: improve route data insertion with timestamp interpolation and error handling --- StrandiOS/Health/HealthKitBridge.swift | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 6d5a178e18..4353e24a82 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -1253,15 +1253,23 @@ final class HealthKitBridge: ObservableObject { store.authorizationStatus(for: HKSeriesType.workoutRoute()) == .sharingAuthorized, let route = RouteStore.load(startTs: row.startTs, sport: row.sport), !route.polyline.isEmpty { - let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local()) - let points = RouteMath.decode(route.polyline) - let locs = points.map { p in - CLLocation(coordinate: CLLocationCoordinate2D(latitude: p.lat, longitude: p.lon), - altitude: 0, horizontalAccuracy: 1, verticalAccuracy: 1, timestamp: start) - } - if !locs.isEmpty { - try await routeBuilder.insertRouteData(locs) - try await routeBuilder.finishRoute(with: workout, metadata: nil) + do { + let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local()) + let points = RouteMath.decode(route.polyline) + let duration = end.timeIntervalSince(start) + let locs = points.enumerated().map { i, p in + // Interpolate timestamps across the workout's span, since RoutePoint is lat/lon only. + let ts = start.addingTimeInterval(duration * Double(i) / Double(max(points.count - 1, 1))) + return CLLocation(coordinate: CLLocationCoordinate2D(latitude: p.lat, longitude: p.lon), + altitude: 0, horizontalAccuracy: 5, verticalAccuracy: -1, timestamp: ts) + } + if !locs.isEmpty { + try await routeBuilder.insertRouteData(locs) + try await routeBuilder.finishRoute(with: workout, metadata: nil) + } + } catch { + // Route is an enrichment; don't fail the whole workout write if the route fails. + print("Failed to attach route to HealthKit workout: \(error)") } } } catch {