Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workout route locations #20

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ And you're all set ! :+1:
* [`multipleQueryHKitSampleType(...)`](#multiplequeryhkitsampletype)
* [`isEditionAuthorized(...)`](#iseditionauthorized)
* [`multipleIsEditionAuthorized()`](#multipleiseditionauthorized)
* [`queryHKitWorkoutRouteLocations(...)`](#queryhkitworkoutroutelocations)
* [Interfaces](#interfaces)

</docgen-index>
Expand Down Expand Up @@ -200,6 +201,23 @@ Checks if there is writing permission for multiple sample types. This function h
--------------------


### queryHKitWorkoutRouteLocations(...)

```typescript
queryHKitWorkoutRouteLocations(queryOptions: SingleQueryOptionsWithUUID) => Promise<QueryOutput<LocationData>>
```

Get workout's route locations

| Param | Type |
| ------------------ | --------------------------------------------------------------------------------- |
| **`queryOptions`** | <code><a href="#singlequeryoptionswithuuid">SingleQueryOptionsWithUUID</a></code> |

**Returns:** <code>Promise&lt;<a href="#queryoutput">QueryOutput</a>&lt;<a href="#locationdata">LocationData</a>&gt;&gt;</code>

--------------------


### Interfaces


Expand Down Expand Up @@ -250,6 +268,35 @@ This is used for checking writing permissions.
| ---------------- | ------------------- |
| **`sampleName`** | <code>string</code> |


#### LocationData

This data points are specific for workout's route location.
https://developer.apple.com/documentation/corelocation/cllocation

| Prop | Type |
| ------------------------ | ------------------- |
| **`timestamp`** | <code>string</code> |
| **`latitude`** | <code>number</code> |
| **`longitude`** | <code>number</code> |
| **`altitude`** | <code>number</code> |
| **`floorLever`** | <code>number</code> |
| **`horizontalAccuracy`** | <code>number</code> |
| **`verticalAccuracy`** | <code>number</code> |
| **`speed`** | <code>number</code> |
| **`speedAccuracy`** | <code>number</code> |
| **`course`** | <code>number</code> |
| **`courseAccuracy`** | <code>number</code> |


#### SingleQueryOptionsWithUUID

This extends the BaseQueryOptions for a sample's id.

| Prop | Type |
| ---------------- | ------------------- |
| **`sampleUUID`** | <code>string</code> |

</docgen-api>


Expand Down
1 change: 1 addition & 0 deletions ios/Plugin/CapacitorHealthkitPlugin.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
CAP_PLUGIN_METHOD(multipleQueryHKitSampleType, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(isEditionAuthorized, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(multipleIsEditionAuthorized, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(queryHKitWorkoutRouteLocations, CAPPluginReturnPromise);
)
167 changes: 166 additions & 1 deletion ios/Plugin/CapacitorHealthkitPlugin.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Capacitor
import HealthKit
import CoreLocation

var healthStore = HKHealthStore()

Expand All @@ -17,6 +18,7 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
case quantityRequestFailed
case sampleTypeFailed
case deniedDataAccessFailed
case workoutRouteRequestFailed

var outputMessage: String {
switch self {
Expand All @@ -30,6 +32,8 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
return "sampleTypeFailed"
case .deniedDataAccessFailed:
return "deniedDataAccessFailed"
case .workoutRouteRequestFailed:
return "workoutRouteRequestFailed"
}
}
}
Expand Down Expand Up @@ -58,6 +62,8 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
return HKWorkoutType.workoutType()
case "weight":
return HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bodyMass)!
case "heartRate":
return HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!
default:
return nil
}
Expand Down Expand Up @@ -86,6 +92,10 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
types.insert(HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bloodGlucose)!)
case "weight":
types.insert(HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bodyMass)!)
case "heartRate":
types.insert(HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!)
case "workoutRoute":
types.insert(HKSeriesType.workoutRoute())
default:
print("no match in case: " + item)
}
Expand Down Expand Up @@ -368,7 +378,10 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
var unit: HKUnit?
var unitName: String?

if sampleName == "weight" {
if sampleName == "heartRate" {
unit = HKUnit(from: "count/min")
unitName = "BPM"
} else if sampleName == "weight" {
unit = HKUnit.gramUnit(with: .kilo)
unitName = "kilogram"
} else if sample.quantityType.is(compatibleWith: HKUnit.meter()) {
Expand Down Expand Up @@ -595,4 +608,156 @@ public class CapacitorHealthkitPlugin: CAPPlugin {
}
healthStore.execute(query)
}

// MARK: - Workout Route

@objc func queryHKitWorkoutRouteLocations(_ call: CAPPluginCall) {
guard let _sampleUUID = call.options["sampleUUID"] as? String else {
return call.reject("Must provide sample uuid")
}
guard let workoutUUID = UUID(uuidString: _sampleUUID) else {
return call.reject("Invalid workout uuid")
}
guard let _limit = call.options["limit"] as? Int else {
return call.reject("Must provide limit")
}
let limit: Int = (_limit == 0) ? HKObjectQueryNoLimit : _limit

Task {
do {
let workout = try await getWorkout(by: workoutUUID)
let workoutRoute = try await getRoute(for: workout)
let locations = try await getLocations(for: workoutRoute, limit: limit)
guard let output: [[String: Any]] = generateOutput(for: workoutRoute, from: locations) else {
return call.reject("Error happened while generating outputs")
}
call.resolve([
"countReturn": output.count,
"resultData": output,
])
} catch {
var errorMessage = ""
if let localError = error as? HKSampleError {
errorMessage = localError.outputMessage
} else {
errorMessage = error.localizedDescription
}
call.reject("Error happened while generating outputs: \(errorMessage)")
}
}
}

// MARK: Helpers

private func getWorkout(by uuid: UUID) async throws -> HKWorkout {
try await withCheckedThrowingContinuation { continuation in
getWorkout(sampleType: HKSampleType.workoutType(), uuid: uuid) { result in
continuation.resume(with: result)
}
}
}
private func getWorkout(sampleType: HKSampleType, uuid: UUID, completion: @escaping(Result<HKWorkout, Error>) -> Void) {
let predicate = HKQuery.predicateForObject(with: uuid)
let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: 1, sortDescriptors: nil)
{ _, samples, error in
if let resultError = error {
return completion(.failure(resultError))
}
if let workouts = samples as? [HKWorkout],
let workout = workouts.first
{
return completion(.success(workout))
}
return completion(.failure(HKSampleError.workoutRequestFailed))
}
healthStore.execute(query)
}

private func getRoute(for workout: HKWorkout) async throws -> HKWorkoutRoute {
try await withCheckedThrowingContinuation { continuation in
getRoute(for: workout) { result in
continuation.resume(with: result)
}
}
}
private func getRoute(for workout: HKWorkout, completion: @escaping (Result<HKWorkoutRoute, Error>) -> Void) {
let predicate = HKQuery.predicateForObjects(from: workout)
let query = HKAnchoredObjectQuery(type: HKSeriesType.workoutRoute(),
predicate: predicate, anchor: nil, limit: HKObjectQueryNoLimit)
{ _, samples, _, _, error in
if let resultError = error {
return completion(.failure(resultError))
}
if let routes = samples as? [HKWorkoutRoute],
let route = routes.first
{
return completion(.success(route))
}
return completion(.failure(HKSampleError.workoutRouteRequestFailed))
}
healthStore.execute(query)
}

private func getLocations(for route: HKWorkoutRoute, limit: Int) async throws -> [CLLocation] {
try await withCheckedThrowingContinuation{ continuation in
getLocations(for: route, limit: limit) { result in
continuation.resume(with: result)
}
}
}
private func getLocations(for route: HKWorkoutRoute, limit: Int, completion: @escaping(Result<[CLLocation], Error>) -> Void) {
var queryLocations = [CLLocation]()
let query = HKWorkoutRouteQuery(route: route) { query, locations, done, error in
if let resultError = error {
return completion(.failure(resultError))
}
if let locationBatch = locations {
queryLocations.append(contentsOf: locationBatch)
}
if done {
completion(.success(queryLocations))
}
}
healthStore.execute(query)
}

private func generateOutput(for route: HKWorkoutRoute, from locations: [CLLocation]) -> [[String: Any]]? {

var output: [[String: Any]] = []

for location in locations {
let quantitySD: NSDate
let quantityED: NSDate
quantitySD = route.startDate as NSDate
quantityED = route.endDate as NSDate
let quantityInterval = quantityED.timeIntervalSince(quantitySD as Date)
let quantitySecondsInAnHour: Double = 3600
let quantityHoursBetweenDates = quantityInterval / quantitySecondsInAnHour
var courseAccuracy = -1.0
if #available(iOS 13.4, *) {
courseAccuracy = location.courseAccuracy
}

output.append([
"uuid": route.uuid.uuidString,
"startDate": ISO8601DateFormatter().string(from: route.startDate),
"endDate": ISO8601DateFormatter().string(from: route.endDate),
"duration": quantityHoursBetweenDates,
"source": route.sourceRevision.source.name,
"sourceBundleId": route.sourceRevision.source.bundleIdentifier,
"timestamp": ISO8601DateFormatter().string(from: location.timestamp),
"latitude": location.coordinate.latitude,
"longitude": location.coordinate.longitude,
"altitude": location.altitude,
"floorLever": location.floor?.level ?? 0,
"horizontalAccuracy": location.horizontalAccuracy,
"verticalAccuracy": location.verticalAccuracy,
"speed": location.speed,
"speedAccuracy": location.speedAccuracy,
"course": location.course,
"courseAccuracy": courseAccuracy
])
}
return output
}
}
29 changes: 29 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export interface CapacitorHealthkitPlugin {
* Checks if there is writing permission for multiple sample types. This function has not been tested - and usually needs a parameter to be able to answer.
*/
multipleIsEditionAuthorized(): Promise<void>;
/**
* Get workout's route locations
*/
queryHKitWorkoutRouteLocations(queryOptions:SingleQueryOptionsWithUUID): Promise<QueryOutput<LocationData>>;
}

/**
Expand Down Expand Up @@ -78,6 +82,24 @@ export interface OtherData extends BaseData {
value: number;
}

/**
* This data points are specific for workout's route location.
* https://developer.apple.com/documentation/corelocation/cllocation
*/
export interface LocationData extends BaseData {
timestamp: string;
latitude: number;
longitude: number;
altitude: number;
floorLever: number;
horizontalAccuracy: number;
verticalAccuracy: number;
speed: number;
speedAccuracy: number;
course: number;
courseAccuracy: number;
}

/**
* These Basequeryoptions are always necessary for a query, they are extended by SingleQueryOptions and MultipleQueryOptions.
*/
Expand All @@ -101,6 +123,12 @@ export interface MultipleQueryOptions extends BaseQueryOptions {
sampleNames: string[];
}

/**
* This extends the BaseQueryOptions for a sample's id.
*/
export interface SingleQueryOptionsWithUUID extends SingleQueryOptions {
sampleUUID: string;
}

/**
* Used for authorization of reading and writing access.
Expand Down Expand Up @@ -134,4 +162,5 @@ export enum SampleNames {
SLEEP_ANALYSIS = 'sleepAnalysis',
WORKOUT_TYPE = 'workoutType',
WEIGHT = 'weight',
HEART_RATE = 'heartRate',
}
5 changes: 5 additions & 0 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
CapacitorHealthkitPlugin,
MultipleQueryOptions,
SingleQueryOptions,
SingleQueryOptionsWithUUID,
} from './definitions';

export class CapacitorHealthkitWeb
Expand Down Expand Up @@ -39,4 +40,8 @@ export class CapacitorHealthkitWeb
async multipleIsEditionAuthorized(): Promise<void> {
throw this.unimplemented('Not implemented on web.');
}

async queryHKitWorkoutRouteLocations(_queryOptions: SingleQueryOptionsWithUUID): Promise<any> {
throw this.unimplemented('Not implemented on web.');
}
}