feat(ios): plugin natif CoachHealthRoute (HKWorkoutRouteQuery)

@capgo/capacitor-health n'expose pas les routes GPS des workouts. Ce
plugin Swift custom appelle HKSampleQuery + HKWorkoutRouteQuery pour
récupérer la liste des CLLocation associées à un workout (par UUID).

API JS :
  CoachHealthRoute.getRoute({workoutUUID})
    → {available: bool, route: [{latitude, longitude, altitude, timestamp, speed}], count}

Format payload aligné sur HAE pour réutiliser le parser backend
existant (tools/health_parser.py).
This commit is contained in:
Sylvain Bettinelli
2026-05-11 14:25:37 +00:00
parent 908c6b2141
commit 8d011610be
2 changed files with 160 additions and 0 deletions

View File

@@ -13,6 +13,7 @@
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; }; C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
@@ -27,6 +28,7 @@
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; };
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; }; E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -74,6 +76,7 @@
504EC3071FED79650016851F /* AppDelegate.swift */, 504EC3071FED79650016851F /* AppDelegate.swift */,
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */,
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */, 504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */, 504EC30E1FED79650016851F /* Assets.xcassets */,
@@ -169,6 +172,7 @@
504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */, C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */, D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@@ -0,0 +1,156 @@
// CoachHealthRoute.swift
// Plugin Capacitor natif iOS : récupère la route GPS d'un workout HealthKit
// (HKWorkoutRoute / CLLocation). Comble le manque de @capgo/capacitor-health
// qui n'expose pas les routes.
//
// Usage côté JS :
// const r = await window.Capacitor.Plugins.CoachHealthRoute.getRoute({
// workoutUUID: '...HKWorkout UUID string...'
// });
// // r.route = [{lat, lon, ts, alt?, speed?}, ...] ordonné chronologiquement
// // r.available = bool (false si pas de route associée, pas d'autorisation, etc.)
import Foundation
import Capacitor
import HealthKit
import CoreLocation
@objc(CoachHealthRoutePlugin)
public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachHealthRoutePlugin"
public let jsName = "CoachHealthRoute"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestAuthorization", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise),
]
private let store = HKHealthStore()
@objc func isAvailable(_ call: CAPPluginCall) {
call.resolve(["available": HKHealthStore.isHealthDataAvailable()])
}
@objc func requestAuthorization(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["granted": false, "reason": "HealthKit unavailable"])
return
}
let types: Set<HKObjectType> = [
HKObjectType.workoutType(),
HKSeriesType.workoutRoute(),
]
store.requestAuthorization(toShare: nil, read: types) { ok, err in
if let err = err {
call.resolve(["granted": false, "reason": err.localizedDescription])
} else {
call.resolve(["granted": ok])
}
}
}
@objc func getRoute(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["available": false, "reason": "HealthKit unavailable", "route": []])
return
}
guard let uuidStr = call.getString("workoutUUID"), let uuid = UUID(uuidString: uuidStr) else {
call.reject("workoutUUID requis (UUID string)")
return
}
// 1. Retrouver le HKWorkout via son UUID.
let pred = HKQuery.predicateForObject(with: uuid)
let wq = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: pred,
limit: 1,
sortDescriptors: nil
) { _, results, err in
if let err = err {
call.reject("Workout fetch failed: \(err.localizedDescription)")
return
}
guard let workout = results?.first as? HKWorkout else {
call.resolve(["available": false, "reason": "workout not found", "route": []])
return
}
self.fetchRoute(for: workout, call: call)
}
store.execute(wq)
}
private func fetchRoute(for workout: HKWorkout, call: CAPPluginCall) {
// 2. Récupère les HKWorkoutRoute samples associés à ce workout (souvent 1).
let routePred = HKQuery.predicateForObjects(from: workout)
let routeQ = HKSampleQuery(
sampleType: HKSeriesType.workoutRoute(),
predicate: routePred,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, err in
if let err = err {
call.reject("Route fetch failed: \(err.localizedDescription)")
return
}
guard let routes = samples as? [HKWorkoutRoute], !routes.isEmpty else {
call.resolve(["available": false, "reason": "no route", "route": []])
return
}
// 3. Pour chaque HKWorkoutRoute, streamer les CLLocation samples.
self.collectLocations(routes: routes, call: call)
}
store.execute(routeQ)
}
private func collectLocations(routes: [HKWorkoutRoute], call: CAPPluginCall) {
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err {
pluginErr = err
group.leave()
return
}
if let locs = locations {
all.append(contentsOf: locs)
}
if done {
group.leave()
}
}
store.execute(q)
}
group.notify(queue: .main) {
if let err = pluginErr {
call.reject("Locations stream failed: \(err.localizedDescription)")
return
}
// Tri chronologique + sérialisation
let sorted = all.sorted { $0.timestamp < $1.timestamp }
// Format HAE-compatible : latitude/longitude/altitude.
// Le parser backend lit ces noms (tools/health_parser.py).
let isoFmt = ISO8601DateFormatter()
let payload: [[String: Any]] = sorted.map { loc in
var dict: [String: Any] = [
"latitude": loc.coordinate.latitude,
"longitude": loc.coordinate.longitude,
"timestamp": isoFmt.string(from: loc.timestamp),
]
if loc.verticalAccuracy >= 0 {
dict["altitude"] = loc.altitude
}
if loc.speed >= 0 {
dict["speed"] = loc.speed // m/s
}
return dict
}
call.resolve(["available": true, "route": payload, "count": payload.count])
}
}
}