I have a functional workout app on watchOS for tracking indoor and outdoor runs. I'm trying to add the outdoor running route to HealthKit using HKWorkoutRouteBuilder. Practical tests during actual outdoor runs are showing only partial route updates on a Map as illustrated below as a small series of blue dots;
Route updates come from CoreLocation which I have set up in the following class.
class LocationManager: NSObject, CLLocationManagerDelegate {
public var globalLocationManager: CLLocationManager?
private var routeBuilder: HKWorkoutRouteBuilder?
public func setUpLocationManager() {
globalLocationManager = CLLocationManager()
globalLocationManager?.delegate = self
globalLocationManager?.desiredAccuracy = kCLLocationAccuracyBest
// Update every 13.5 meters in order to achieve updates no faster than once every 3sec.
// This assumes runner is running at no faster than 6min/mile - 3.7min/km
globalLocationManager?.distanceFilter = 13.5
// Can use `kCLDistanceFilterNone` which will give more updates but still only at wide intervals.
globalLocationManager?.activityType = .fitness
/*
from the docs
...if your app needs to receive location events while in the background,
it must include the UIBackgroundModes key (with the location value) in its Info.plist file.
*/
routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
globalLocationManager?.startUpdatingLocation()
globalLocationManager?.allowsBackgroundLocationUpdates = true
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// Filter the raw data, excluding anything greater than 50m accuracy
let filteredLocations = locations.filter { isAccurateTo -> Bool in
isAccurateTo.horizontalAccuracy <= 50
}
guard !filteredLocations.isEmpty else { return }
routeBuilder?.insertRouteData(filteredLocations, completion: { success, error in
if error != nil {
// throw alert due to error in saving route.
print("Error in \(#function) \(error?.localizedDescription ?? "Error in Route Builder")")
}
})
}
// Called in class WorkoutController when workout session ends.
public func addRoute(to workout: HKWorkout) {
routeBuilder?.finishRoute(with: workout, metadata: nil, completion: { workoutRoute, error in
if workoutRoute == nil {
fatalError("error saving workout route")
}
})
}
}
The route is then added to a SwiftUI Map using HKAnchoredObjectQuery with the following;
public func getRouteFrom(workout: HKWorkout) {
let mapDisplayAreaPadding = 1.3
let runningObjectQuery = HKQuery.predicateForObjects(from: workout)
let routeQuery = HKAnchoredObjectQuery(type: HKSeriesType.workoutRoute(), predicate: runningObjectQuery, anchor: nil, limit: HKObjectQueryNoLimit) { (query, samples, deletedObjects, anchor, error) in
guard error == nil else {
fatalError("The initial query failed.")
}
// Make sure you have some route samples
guard samples!.count > 0 else {
return
}
let route = samples?.first as! HKWorkoutRoute
// Create the route query from HealthKit.
let query = HKWorkoutRouteQuery(route: route) { (query, locationsOrNil, done, errorOrNil) in
// This block may be called multiple times.
if let error = errorOrNil {
print("Error \(error.localizedDescription)")
return
}
guard let locations = locationsOrNil else {
fatalError("*** NIL found in locations ***")
}
let latitudes = locations.map {
$0.coordinate.latitude
}
let longitudes = locations.map {
$0.coordinate.longitude
}
// Outline map region to display
guard let maxLat = latitudes.max() else { fatalError("Unable to get maxLat") }
guard let minLat = latitudes.min() else { return }
guard let maxLong = longitudes.max() else { return }
guard let minLong = longitudes.min() else { return }
if done {
let mapCenter = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLong + maxLong) / 2)
let mapSpan = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * mapDisplayAreaPadding,
longitudeDelta: (maxLong - minLong) * mapDisplayAreaPadding)
DispatchQueue.main.async {
// Push to main thread to drop dots on the map.
// Without this a warning will occur.
self.region = MKCoordinateRegion(center: mapCenter, span: mapSpan)
locations.forEach { (location) in
self.overlayRoute(at: location)
}
}
}
// stop the query by calling:
// store.stop(query)
}
healthStore.execute(query)
}
routeQuery.updateHandler = { (query, samples, deleted, anchor, error) in
guard error == nil else {
// Handle any errors here.
fatalError("The update failed.")
}
// Process updates or additions here.
}
healthStore.execute(routeQuery)
}
I cannot determine why the map annotations are only displaying in bursts. I have changed the CLLocationManager.distanceFilter to varying values as well as kCLDistanceFilterNone. For CoreLocation authorization I have used whileInUse as well as Always authorization with no change in update frequency. It does appear the updates come in intervals of time, not distance traveled but I can't be entirely certain. Having the app active on screen as well as in the background appears to have no effect on location updates.
Any insight is greatly appreciated.
Below is the HealthKit types to read and types to share setup code:
public func setupWorkoutSession() {
let authorizationStatus = healthStore.authorizationStatus(for: HKWorkoutType.workoutType())
let typesToShare: Set = [HKQuantityType.workoutType(), HKSeriesType.workoutRoute()]
let typesToRead: Set = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKSeriesType.workoutType(),
HKSeriesType.workoutRoute()
]
if authorizationStatus == .sharingDenied {
showAlertView()
return
}
// Request authorization for those quantity types.
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
if success {
self.beginWorkout()
} else if error != nil {
// Handle errors.
}
}
}
