Skip to content

feat(iOS): Add Apple Health workout route (GPX) sync support - #2341

Open
rodrigosa7 wants to merge 2 commits into
ryanbr:mainfrom
rodrigosa7:feature/healthkit-workout-routes
Open

rodrigosa7 wants to merge 2 commits into
ryanbr:mainfrom
rodrigosa7:feature/healthkit-workout-routes

Conversation

@rodrigosa7

Copy link
Copy Markdown

Summary

This PR implements workout route (GPX) synchronization from NOOP to Apple Health for outdoor activities.

Changes

  • Added HKSeriesType.workoutRoute() to HealthKit sharing permissions in HealthKitBridge.
  • Updated writeWorkouts to load polyline data from RouteStore for GPS-enabled sports.
  • Implemented route attachment using HKWorkoutRouteBuilder to associate GPS points with the HKWorkout object.
  • Added validation to ensure routes are only synced for sports that support distance/GPS tracking (Running, Walking, Hiking, Cycling).

Verification

  • Verified code architecture remains consistent with existing WhoopStore and HealthKitBridge patterns.
  • Changes are isolated to the iOS-only StrandiOS target.
  • Build verified locally via xcodebuild.

Refs #2340

…--trailer "Co-authored-by: Junie <junie@jetbrains.com>"
@rodrigosa7 rodrigosa7 changed the title Add Apple Health workout route (GPX) sync support. Refs #1314. --trai… feat(iOS): Add Apple Health workout route (GPX) sync support Sep 19, 2026
@ryanbr

ryanbr commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Thanks @rodrigosa7. The shape is right: permission added, gated on authorizationStatus == .sharingAuthorized, restricted to sports that carry a distance, and the route attached after the workout is finished. CoreLocation is already imported, so that part is fine. Three things before it lands, and the first two put invented data in someone's Health store.

Every point carries the same timestamp

CLLocation(..., timestamp: start)

start for all of them, so the route has zero duration. HealthKit treats a route as a time series, and anything deriving pace or speed from it reads the whole track as happening in an instant.

The cause is upstream: RoutePoint is lat/lon only, so there are no real times to use. Interpolating across the workout's own span is the honest substitute, start + (end - start) * Double(i) / Double(max(points.count - 1, 1)), which at least gives a monotonic track over the right interval. Worth a comment saying the times are derived rather than recorded, since they are.

The altitude is fabricated, and claims to be accurate

altitude: 0, horizontalAccuracy: 1, verticalAccuracy: 1

verticalAccuracy: 1 asserts the altitude is known to within a metre, and altitude: 0 says that altitude is sea level. Neither is true, and Apple's own convention is a NEGATIVE accuracy for "this field is not valid". Passing verticalAccuracy: -1 says "no altitude" honestly and costs nothing.

horizontalAccuracy: 1 has the same problem in a smaller way: a metre is a confident claim for a track that has been through a polyline round-trip. Whatever the recorder knew, if anything, is the number to use, and something conservative otherwise.

A route failure reports the workout as failed

The route block sits after finishWorkout() but inside the same do, whose catch runs builder.discardWorkout() and rethrows. By then the workout is already committed, so a throw from insertRouteData or finishRoute discards nothing and tells the caller the workout write failed. If anything retries on that, the wearer gets the workout twice.

The route is an enrichment, not part of the workout's success. Its own do { ... } catch { log } after the existing block keeps a bad route from rewriting history.

One process note

No checks ran on this PR. app-build.yml is the only workflow that compiles the iOS target and it is disabled by default, so nothing here has been type-checked. Please say what you built locally, or I can dispatch that workflow against the branch.

@rodrigosa7

rodrigosa7 commented Sep 20, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review, Ryan! I've implemented all the requested changes:

  1. Timestamps: Fixed the 'zero duration' issue by interpolating the RoutePoint timestamps across the workout's actual start and end times. Added a comment noting they are derived.
  2. Altitude & Accuracy: Set verticalAccuracy to -1 to correctly signal 'no altitude data' and avoid polluting Health with fake 0m readings. I also adjusted horizontalAccuracy to a more conservative 5m.
  3. Error Isolation: Moved the route attachment into its own do-catch block. Now, a failure to attach the route won't cause the entire workout to be discarded or duplicated.

Regarding the altitude: I see that RoutePoint is currently lat/lon only. I agree that -1 is the right move for now to keep the data honest, but maybe we should look into extending the schema to store elevation in a future update!

@ryanbr

ryanbr commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Reviewed at 152afb8. CI is 4/4 green including both compile legs, the description is accurate about what it does, and the HealthKit plumbing is mostly right. I want to hold this one anyway, because of what it writes.

The blocking issue: the timestamps are invented

// 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)))

The comment is honest about what it is doing, and its premise is true as far as it goes: the stored type carries no time. But the recorder had the real times. GpsWorkoutRecorder.swift:507-515 maps every CLLocation into a RawFix carrying tMs: Int64($0.timestamp.timeIntervalSince1970 * 1000), and TrackFilter bound-checks it. The times survive the filter, and are then dropped, because WorkoutRoute persists exactly two fields: polyline and distanceM.

So this is not reconstructing something that was never measured. It is re-inventing something that was measured, filtered, and then discarded one layer down.

What ends up written is a route whose every segment implies identical pace. Apple's Fitness app, and every third-party HealthKit reader, computes speed and splits from route timestamps, so a run with a hill in it becomes a flat trace, permanently, in the user's health record. The distance NOOP writes separately stays correct, which makes it worse rather than better: the summary and the trace become two readouts of one workout that can disagree.

horizontalAccuracy: 5 is the same problem in miniature. RawFix.accuracyM is captured from CoreLocation and bound-checked, then dropped at persist, and a flat 5 metres goes out in its place. (verticalAccuracy: -1 is exactly right, and correctly marks altitude as unknown. The contrast is what tells me you already know the idiom.)

The repair belongs one layer down: put per-point time and accuracy into WorkoutRoute, and then this code writes true values and gets shorter rather than longer. One wrinkle for whoever does it: routes already on disk cannot be healed, so the write wants to be gated on routes that actually carry times, rather than backfilling the old ones with a guess.

Two smaller things

  • The code comment points at the wrong issue. It reads #1314, which is about self-hosted sync destinations and is unrelated to this work. The description has it right at Support Apple Health Workout Route (GPX) Sync #2340. These anchors get grepped, so a wrong one is worse than none at all.
  • print(...) is the only one in a 1300-line file. The convention here is either lastError (user-visible, as at line 745) or a silent catch with a comment saying why swallowing is safe (lines 201, 694). A debug print is invisible in release regardless.

Minor: the route builder is not discarded on the locs.isEmpty or throw paths. Reachable when a stored polyline is non-empty but decodes to nothing.

What is genuinely good

CoreLocation was already imported, so no import churn. distanceTypeId(forSport:) is the correct existing gate and you reused it rather than reinventing the sport list. The authorizationStatus == .sharingAuthorized check is properly defensive. And letting a route failure not take the workout write down with it is the right call.

Suggestion

Split it in two. Land per-point time and accuracy in WorkoutRoute first, then this PR lands close to as-is and writes real data. Happy to take the store half myself if you would rather keep the HealthKit half.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants