@appeeky/expo-healthkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +92 -0
- package/README.md +453 -0
- package/android/build.gradle +22 -0
- package/android/src/main/AndroidManifest.xml +74 -0
- package/android/src/main/java/expo/modules/healthkit/ExpoHealthKitModule.kt +228 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectMapping.kt +129 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectNutrition.kt +152 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectRationaleActivity.kt +62 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectService.kt +1054 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectUnits.kt +133 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectWorkouts.kt +105 -0
- package/android/src/main/java/expo/modules/healthkit/HealthKitExceptions.kt +30 -0
- package/app.plugin.js +1 -0
- package/build/ExpoHealthKitModule.d.ts +39 -0
- package/build/ExpoHealthKitModule.d.ts.map +1 -0
- package/build/ExpoHealthKitModule.js +35 -0
- package/build/ExpoHealthKitModule.js.map +1 -0
- package/build/ExpoHealthKitModule.web.d.ts +39 -0
- package/build/ExpoHealthKitModule.web.d.ts.map +1 -0
- package/build/ExpoHealthKitModule.web.js +105 -0
- package/build/ExpoHealthKitModule.web.js.map +1 -0
- package/build/dates.d.ts +6 -0
- package/build/dates.d.ts.map +1 -0
- package/build/dates.js +13 -0
- package/build/dates.js.map +1 -0
- package/build/errors.d.ts +9 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +18 -0
- package/build/errors.js.map +1 -0
- package/build/identifiers.d.ts +418 -0
- package/build/identifiers.d.ts.map +1 -0
- package/build/identifiers.js +402 -0
- package/build/identifiers.js.map +1 -0
- package/build/index.d.ts +454 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +418 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +542 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/docs/banner.jpg +0 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoHealthKit.podspec +30 -0
- package/ios/ExpoHealthKitModule.swift +155 -0
- package/ios/HealthKitAdvancedQueries.swift +458 -0
- package/ios/HealthKitExceptions.swift +109 -0
- package/ios/HealthKitIdentifiers.swift +183 -0
- package/ios/HealthKitRecords.swift +214 -0
- package/ios/HealthKitService.swift +626 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/package.json +105 -0
- package/plugin/build/index.d.ts +30 -0
- package/plugin/build/index.js +138 -0
- package/src/ExpoHealthKitModule.ts +116 -0
- package/src/ExpoHealthKitModule.web.ts +140 -0
- package/src/dates.ts +17 -0
- package/src/errors.ts +22 -0
- package/src/identifiers.ts +494 -0
- package/src/index.ts +563 -0
- package/src/types.ts +625 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class ExpoHealthKitModule: Module {
|
|
4
|
+
private let service = HealthKitService()
|
|
5
|
+
|
|
6
|
+
public func definition() -> ModuleDefinition {
|
|
7
|
+
Name("ExpoHealthKit")
|
|
8
|
+
|
|
9
|
+
Events("onUpdate")
|
|
10
|
+
|
|
11
|
+
OnCreate {
|
|
12
|
+
self.service.onUpdate = { type in
|
|
13
|
+
self.sendEvent("onUpdate", [
|
|
14
|
+
"type": type
|
|
15
|
+
])
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
OnDestroy {
|
|
20
|
+
self.service.stopObserving()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Function("isHealthDataAvailable") {
|
|
24
|
+
self.service.isAvailable()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AsyncFunction("requestAuthorization") { (options: AuthorizationOptions) in
|
|
28
|
+
try await self.service.requestAuthorization(options)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
AsyncFunction("getAuthorizationStatus") { (identifier: String) in
|
|
32
|
+
try self.service.authorizationStatus(for: identifier)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
AsyncFunction("getRequestStatusForAuthorization") { (options: AuthorizationOptions) in
|
|
36
|
+
try await self.service.requestStatusForAuthorization(options)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
AsyncFunction("queryQuantitySamples") { (options: QuantityQueryOptions) in
|
|
40
|
+
try await self.service.queryQuantitySamples(options)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
AsyncFunction("queryCategorySamples") { (options: CategoryQueryOptions) in
|
|
44
|
+
try await self.service.queryCategorySamples(options)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
AsyncFunction("queryWorkouts") { (options: WorkoutQueryOptions) in
|
|
48
|
+
try await self.service.queryWorkouts(options)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
AsyncFunction("queryElectrocardiograms") { (options: ElectrocardiogramQueryOptions) in
|
|
52
|
+
try await self.service.queryElectrocardiograms(options)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
AsyncFunction("queryActivitySummaries") { (options: ActivitySummaryQueryOptions) in
|
|
56
|
+
try await self.service.queryActivitySummaries(options)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
AsyncFunction("queryClinicalRecords") { (options: ClinicalRecordQueryOptions) in
|
|
60
|
+
try await self.service.queryClinicalRecords(options)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
AsyncFunction("queryAudiograms") { (options: DateRangeQueryOptions) in
|
|
64
|
+
try await self.service.queryAudiograms(options)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
AsyncFunction("queryWorkoutRoute") { (options: WorkoutRouteQueryOptions) in
|
|
68
|
+
try await self.service.queryWorkoutRoute(options)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
AsyncFunction("queryCorrelations") { (options: CorrelationQueryOptions) in
|
|
72
|
+
try await self.service.queryCorrelations(options)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
AsyncFunction("saveCorrelation") { (input: CorrelationInput) in
|
|
76
|
+
try await self.service.saveCorrelation(input)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
AsyncFunction("queryHeartbeatSeries") { (options: HeartbeatSeriesQueryOptions) in
|
|
80
|
+
try await self.service.queryHeartbeatSeries(options)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
AsyncFunction("queryStatistics") { (options: StatisticsQueryOptions) in
|
|
84
|
+
try await self.service.queryStatistics(options)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
AsyncFunction("queryStatisticsCollection") { (options: StatisticsCollectionQueryOptions) in
|
|
88
|
+
try await self.service.queryStatisticsCollection(options)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
AsyncFunction("queryAnchored") { (options: AnchoredQueryOptions) in
|
|
92
|
+
try await self.service.queryAnchored(options)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
AsyncFunction("saveQuantitySample") { (sample: QuantitySampleInput) in
|
|
96
|
+
try await self.service.saveQuantitySample(sample)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
AsyncFunction("saveCategorySample") { (sample: CategorySampleInput) in
|
|
100
|
+
try await self.service.saveCategorySample(sample)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
AsyncFunction("saveWorkout") { (workout: WorkoutInput) in
|
|
104
|
+
try await self.service.saveWorkout(workout)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
AsyncFunction("deleteObjects") { (options: DeleteObjectsOptions) in
|
|
108
|
+
try await self.service.deleteObjects(options)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
AsyncFunction("getBiologicalSex") {
|
|
112
|
+
try self.service.biologicalSex()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
AsyncFunction("getBloodType") {
|
|
116
|
+
try self.service.bloodType()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
AsyncFunction("getDateOfBirth") {
|
|
120
|
+
try self.service.dateOfBirth()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
AsyncFunction("getFitzpatrickSkinType") {
|
|
124
|
+
try self.service.fitzpatrickSkinType()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
AsyncFunction("getWheelchairUse") {
|
|
128
|
+
try self.service.wheelchairUse()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
AsyncFunction("enableBackgroundDelivery") { (type: String, frequency: Int) in
|
|
132
|
+
try await self.service.enableBackgroundDelivery(type: type, frequency: frequency)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
AsyncFunction("disableBackgroundDelivery") { (type: String) in
|
|
136
|
+
try await self.service.disableBackgroundDelivery(type: type)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
AsyncFunction("disableAllBackgroundDelivery") {
|
|
140
|
+
try await self.service.disableAllBackgroundDelivery()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
AsyncFunction("observeTypes") { (types: [String]) in
|
|
144
|
+
try self.service.startObserving(types)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
AsyncFunction("clearObserverQueries") {
|
|
148
|
+
self.service.stopObserving()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
OnStopObserving("onUpdate") {
|
|
152
|
+
self.service.stopObserving()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import HealthKit
|
|
3
|
+
|
|
4
|
+
extension HealthKitService {
|
|
5
|
+
func queryElectrocardiograms(_ options: ElectrocardiogramQueryOptions) async throws -> [[String: Any]] {
|
|
6
|
+
try ensureAvailable()
|
|
7
|
+
let samples = try await sampleQuery(
|
|
8
|
+
sampleType: HKObjectType.electrocardiogramType(),
|
|
9
|
+
from: options.from,
|
|
10
|
+
to: options.to,
|
|
11
|
+
limit: options.limit,
|
|
12
|
+
ascending: options.ascending
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
var records: [[String: Any]] = []
|
|
16
|
+
for case let ecg as HKElectrocardiogram in samples {
|
|
17
|
+
var record = mapElectrocardiogram(ecg)
|
|
18
|
+
if options.includeVoltage {
|
|
19
|
+
record["voltageMeasurements"] = try await voltageMeasurements(for: ecg)
|
|
20
|
+
}
|
|
21
|
+
records.append(record)
|
|
22
|
+
}
|
|
23
|
+
return records
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func queryAudiograms(_ options: DateRangeQueryOptions) async throws -> [[String: Any]] {
|
|
27
|
+
try ensureAvailable()
|
|
28
|
+
let samples = try await sampleQuery(
|
|
29
|
+
sampleType: HKObjectType.audiogramSampleType(),
|
|
30
|
+
from: options.from,
|
|
31
|
+
to: options.to,
|
|
32
|
+
limit: options.limit,
|
|
33
|
+
ascending: options.ascending
|
|
34
|
+
)
|
|
35
|
+
return samples.compactMap { sample in
|
|
36
|
+
guard let audiogram = sample as? HKAudiogramSample else {
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
return mapAudiogram(audiogram)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func queryActivitySummaries(_ options: ActivitySummaryQueryOptions) async throws -> [[String: Any]] {
|
|
44
|
+
try ensureAvailable()
|
|
45
|
+
let summaries: [HKActivitySummary] = try await withCheckedThrowingContinuation { continuation in
|
|
46
|
+
do {
|
|
47
|
+
try catchingHealthKit {
|
|
48
|
+
let predicate = try self.activitySummaryPredicate(from: options.from, to: options.to)
|
|
49
|
+
let query = HKActivitySummaryQuery(predicate: predicate) { _, result, error in
|
|
50
|
+
if let error {
|
|
51
|
+
continuation.resume(throwing: error)
|
|
52
|
+
} else {
|
|
53
|
+
continuation.resume(returning: result ?? [])
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
self.store.execute(query)
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
continuation.resume(throwing: error)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return summaries.map(mapActivitySummary)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func queryClinicalRecords(_ options: ClinicalRecordQueryOptions) async throws -> [[String: Any]] {
|
|
66
|
+
try ensureAvailable()
|
|
67
|
+
let sampleType = try HealthKitIdentifiers.sampleType(for: options.type)
|
|
68
|
+
guard sampleType is HKClinicalType else {
|
|
69
|
+
throw InvalidIdentifierException(options.type)
|
|
70
|
+
}
|
|
71
|
+
let samples = try await sampleQuery(
|
|
72
|
+
sampleType: sampleType,
|
|
73
|
+
from: options.from,
|
|
74
|
+
to: options.to,
|
|
75
|
+
limit: options.limit,
|
|
76
|
+
ascending: options.ascending
|
|
77
|
+
)
|
|
78
|
+
return samples.compactMap { sample in
|
|
79
|
+
guard let record = sample as? HKClinicalRecord else {
|
|
80
|
+
return nil
|
|
81
|
+
}
|
|
82
|
+
return mapClinicalRecord(record)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func queryWorkoutRoute(_ options: WorkoutRouteQueryOptions) async throws -> [[String: Any]] {
|
|
87
|
+
try ensureAvailable()
|
|
88
|
+
guard let uuid = UUID(uuidString: options.workoutUUID) else {
|
|
89
|
+
throw MissingUuidException()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let workouts = try await executeSampleQuery(
|
|
93
|
+
sampleType: HKObjectType.workoutType(),
|
|
94
|
+
predicate: HKQuery.predicateForObject(with: uuid),
|
|
95
|
+
limit: 1,
|
|
96
|
+
ascending: false
|
|
97
|
+
)
|
|
98
|
+
guard let workout = workouts.first as? HKWorkout else {
|
|
99
|
+
throw SampleNotFoundException(options.workoutUUID)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let routes = try await executeSampleQuery(
|
|
103
|
+
sampleType: HKSeriesType.workoutRoute(),
|
|
104
|
+
predicate: HKQuery.predicateForObjects(from: workout),
|
|
105
|
+
limit: HKObjectQueryNoLimit,
|
|
106
|
+
ascending: true
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
var records: [[String: Any]] = []
|
|
110
|
+
for case let route as HKWorkoutRoute in routes {
|
|
111
|
+
let locations = try await routeLocations(for: route)
|
|
112
|
+
records.append(mapWorkoutRoute(route, locations: locations))
|
|
113
|
+
}
|
|
114
|
+
return records
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func queryCorrelations(_ options: CorrelationQueryOptions) async throws -> [[String: Any]] {
|
|
118
|
+
try ensureAvailable()
|
|
119
|
+
let sampleType = try HealthKitIdentifiers.sampleType(for: options.type)
|
|
120
|
+
guard sampleType is HKCorrelationType else {
|
|
121
|
+
throw InvalidIdentifierException(options.type)
|
|
122
|
+
}
|
|
123
|
+
let samples = try await sampleQuery(
|
|
124
|
+
sampleType: sampleType,
|
|
125
|
+
from: options.from,
|
|
126
|
+
to: options.to,
|
|
127
|
+
limit: options.limit,
|
|
128
|
+
ascending: options.ascending
|
|
129
|
+
)
|
|
130
|
+
return samples.compactMap { sample in
|
|
131
|
+
guard let correlation = sample as? HKCorrelation else {
|
|
132
|
+
return nil
|
|
133
|
+
}
|
|
134
|
+
return mapCorrelation(correlation)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func saveCorrelation(_ input: CorrelationInput) async throws -> String {
|
|
139
|
+
try ensureAvailable()
|
|
140
|
+
guard !input.objects.isEmpty else {
|
|
141
|
+
throw EmptyCorrelationException()
|
|
142
|
+
}
|
|
143
|
+
let sampleType = try HealthKitIdentifiers.sampleType(for: input.type)
|
|
144
|
+
guard let correlationType = sampleType as? HKCorrelationType else {
|
|
145
|
+
throw InvalidIdentifierException(input.type)
|
|
146
|
+
}
|
|
147
|
+
let start = try HealthKitIdentifiers.date(from: input.startDate)
|
|
148
|
+
let end = try HealthKitIdentifiers.date(from: input.endDate)
|
|
149
|
+
let objects = Set(try input.objects.map { try makeQuantitySample($0) })
|
|
150
|
+
let correlation = HKCorrelation(
|
|
151
|
+
type: correlationType,
|
|
152
|
+
start: start,
|
|
153
|
+
end: end,
|
|
154
|
+
objects: objects,
|
|
155
|
+
metadata: input.metadata
|
|
156
|
+
)
|
|
157
|
+
try await store.save(correlation)
|
|
158
|
+
return correlation.uuid.uuidString
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func queryHeartbeatSeries(_ options: HeartbeatSeriesQueryOptions) async throws -> [[String: Any]] {
|
|
162
|
+
try ensureAvailable()
|
|
163
|
+
let samples = try await sampleQuery(
|
|
164
|
+
sampleType: HKSeriesType.heartbeat(),
|
|
165
|
+
from: options.from,
|
|
166
|
+
to: options.to,
|
|
167
|
+
limit: options.limit,
|
|
168
|
+
ascending: options.ascending
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
var records: [[String: Any]] = []
|
|
172
|
+
for case let series as HKHeartbeatSeriesSample in samples {
|
|
173
|
+
var record = mapHeartbeatSeries(series)
|
|
174
|
+
if options.includeBeats {
|
|
175
|
+
record["beats"] = try await heartbeatBeats(for: series)
|
|
176
|
+
}
|
|
177
|
+
records.append(record)
|
|
178
|
+
}
|
|
179
|
+
return records
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func mapCorrelation(_ correlation: HKCorrelation) -> [String: Any] {
|
|
183
|
+
let objects = correlation.objects.compactMap { object -> [String: Any]? in
|
|
184
|
+
guard let quantity = object as? HKQuantitySample else {
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
return mapQuantitySample(quantity)
|
|
188
|
+
}
|
|
189
|
+
var record: [String: Any] = [
|
|
190
|
+
"uuid": correlation.uuid.uuidString,
|
|
191
|
+
"type": correlation.correlationType.identifier,
|
|
192
|
+
"startDate": HealthKitIdentifiers.isoString(from: correlation.startDate),
|
|
193
|
+
"endDate": HealthKitIdentifiers.isoString(from: correlation.endDate),
|
|
194
|
+
"objects": objects
|
|
195
|
+
]
|
|
196
|
+
record["sourceName"] = correlation.sourceRevision.source.name
|
|
197
|
+
record["sourceId"] = correlation.sourceRevision.source.bundleIdentifier
|
|
198
|
+
record["metadata"] = HealthKitIdentifiers.stringifyMetadata(correlation.metadata)
|
|
199
|
+
return record
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func mapHeartbeatSeries(_ sample: HKHeartbeatSeriesSample) -> [String: Any] {
|
|
203
|
+
var record: [String: Any] = [
|
|
204
|
+
"uuid": sample.uuid.uuidString,
|
|
205
|
+
"type": "HKDataTypeIdentifierHeartbeatSeries",
|
|
206
|
+
"startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
|
|
207
|
+
"endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
|
|
208
|
+
"count": sample.count
|
|
209
|
+
]
|
|
210
|
+
record["sourceName"] = sample.sourceRevision.source.name
|
|
211
|
+
record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
|
|
212
|
+
record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
|
|
213
|
+
return record
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private func heartbeatBeats(for series: HKHeartbeatSeriesSample) async throws -> [[String: Any]] {
|
|
217
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
218
|
+
var beats: [[String: Any]] = []
|
|
219
|
+
var finished = false
|
|
220
|
+
let query = HKHeartbeatSeriesQuery(heartbeatSeries: series) { _, timeSinceStart, precededByGap, done, error in
|
|
221
|
+
if let error {
|
|
222
|
+
guard !finished else { return }
|
|
223
|
+
finished = true
|
|
224
|
+
continuation.resume(throwing: error)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
beats.append([
|
|
228
|
+
"timeSinceSeriesStart": timeSinceStart,
|
|
229
|
+
"precededByGap": precededByGap
|
|
230
|
+
])
|
|
231
|
+
if done {
|
|
232
|
+
guard !finished else { return }
|
|
233
|
+
finished = true
|
|
234
|
+
continuation.resume(returning: beats)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
executeQuery(query, continuation: continuation)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private func voltageMeasurements(for electrocardiogram: HKElectrocardiogram) async throws -> [[String: Any]] {
|
|
242
|
+
let microvolt = HKUnit.voltUnit(with: .micro)
|
|
243
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
244
|
+
var measurements: [[String: Any]] = []
|
|
245
|
+
var finished = false
|
|
246
|
+
let query = HKElectrocardiogramQuery(electrocardiogram) { _, result in
|
|
247
|
+
switch result {
|
|
248
|
+
case .error(let error):
|
|
249
|
+
guard !finished else { return }
|
|
250
|
+
finished = true
|
|
251
|
+
continuation.resume(throwing: error)
|
|
252
|
+
case .measurement(let measurement):
|
|
253
|
+
if let quantity = measurement.quantity(for: .appleWatchSimilarToLeadI) {
|
|
254
|
+
measurements.append([
|
|
255
|
+
"timeSinceSampleStart": measurement.timeSinceSampleStart,
|
|
256
|
+
"voltage": quantity.doubleValue(for: microvolt),
|
|
257
|
+
"unit": "uV"
|
|
258
|
+
])
|
|
259
|
+
}
|
|
260
|
+
case .done:
|
|
261
|
+
guard !finished else { return }
|
|
262
|
+
finished = true
|
|
263
|
+
continuation.resume(returning: measurements)
|
|
264
|
+
@unknown default:
|
|
265
|
+
guard !finished else { return }
|
|
266
|
+
finished = true
|
|
267
|
+
continuation.resume(returning: measurements)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
executeQuery(query, continuation: continuation)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func routeLocations(for route: HKWorkoutRoute) async throws -> [CLLocation] {
|
|
275
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
276
|
+
var locations: [CLLocation] = []
|
|
277
|
+
var finished = false
|
|
278
|
+
let query = HKWorkoutRouteQuery(route: route) { _, batch, done, error in
|
|
279
|
+
if let error {
|
|
280
|
+
guard !finished else { return }
|
|
281
|
+
finished = true
|
|
282
|
+
continuation.resume(throwing: error)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if let batch {
|
|
286
|
+
locations.append(contentsOf: batch)
|
|
287
|
+
}
|
|
288
|
+
if done {
|
|
289
|
+
guard !finished else { return }
|
|
290
|
+
finished = true
|
|
291
|
+
continuation.resume(returning: locations)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
executeQuery(query, continuation: continuation)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private func activitySummaryPredicate(from: String?, to: String?) throws -> NSPredicate? {
|
|
299
|
+
let start = try HealthKitIdentifiers.optionalDate(from: from)
|
|
300
|
+
let end = try HealthKitIdentifiers.optionalDate(from: to)
|
|
301
|
+
guard start != nil || end != nil else {
|
|
302
|
+
return nil
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
var calendar = Calendar.current
|
|
306
|
+
calendar.timeZone = TimeZone.current
|
|
307
|
+
|
|
308
|
+
let startDate = start ?? calendar.startOfDay(for: end ?? Date())
|
|
309
|
+
let endDate = end ?? Date()
|
|
310
|
+
let earlier = min(startDate, endDate)
|
|
311
|
+
let later = max(startDate, endDate)
|
|
312
|
+
|
|
313
|
+
var startComponents = calendar.dateComponents([.era, .year, .month, .day], from: earlier)
|
|
314
|
+
var endComponents = calendar.dateComponents([.era, .year, .month, .day], from: later)
|
|
315
|
+
startComponents.calendar = calendar
|
|
316
|
+
endComponents.calendar = calendar
|
|
317
|
+
startComponents.timeZone = calendar.timeZone
|
|
318
|
+
endComponents.timeZone = calendar.timeZone
|
|
319
|
+
return try catchingHealthKit {
|
|
320
|
+
HKQuery.predicate(forActivitySummariesBetweenStart: startComponents, end: endComponents)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func mapElectrocardiogram(_ sample: HKElectrocardiogram) -> [String: Any] {
|
|
325
|
+
var record: [String: Any] = [
|
|
326
|
+
"uuid": sample.uuid.uuidString,
|
|
327
|
+
"type": "HKElectrocardiogramTypeIdentifier",
|
|
328
|
+
"startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
|
|
329
|
+
"endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
|
|
330
|
+
"classification": Int(sample.classification.rawValue),
|
|
331
|
+
"symptomsStatus": Int(sample.symptomsStatus.rawValue),
|
|
332
|
+
"numberOfVoltageMeasurements": sample.numberOfVoltageMeasurements
|
|
333
|
+
]
|
|
334
|
+
if let heartRate = sample.averageHeartRate {
|
|
335
|
+
record["averageHeartRate"] = heartRate.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
|
|
336
|
+
}
|
|
337
|
+
if let frequency = sample.samplingFrequency {
|
|
338
|
+
record["samplingFrequency"] = frequency.doubleValue(for: .hertz())
|
|
339
|
+
}
|
|
340
|
+
record["sourceName"] = sample.sourceRevision.source.name
|
|
341
|
+
record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
|
|
342
|
+
record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
|
|
343
|
+
return record
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private func mapAudiogram(_ sample: HKAudiogramSample) -> [String: Any] {
|
|
347
|
+
let hertz = HKUnit.hertz()
|
|
348
|
+
let hearingLevel = HKUnit.decibelHearingLevel()
|
|
349
|
+
let points: [[String: Any]] = sample.sensitivityPoints.map { point in
|
|
350
|
+
var item: [String: Any] = [
|
|
351
|
+
"frequency": point.frequency.doubleValue(for: hertz)
|
|
352
|
+
]
|
|
353
|
+
var left = point.leftEarSensitivity?.doubleValue(for: hearingLevel)
|
|
354
|
+
var right = point.rightEarSensitivity?.doubleValue(for: hearingLevel)
|
|
355
|
+
if #available(iOS 18.1, *) {
|
|
356
|
+
for test in point.tests {
|
|
357
|
+
let value = test.sensitivity.doubleValue(for: hearingLevel)
|
|
358
|
+
switch test.side {
|
|
359
|
+
case .left:
|
|
360
|
+
left = left ?? value
|
|
361
|
+
case .right:
|
|
362
|
+
right = right ?? value
|
|
363
|
+
@unknown default:
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if let left {
|
|
369
|
+
item["leftEarSensitivity"] = left
|
|
370
|
+
}
|
|
371
|
+
if let right {
|
|
372
|
+
item["rightEarSensitivity"] = right
|
|
373
|
+
}
|
|
374
|
+
return item
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
var record: [String: Any] = [
|
|
378
|
+
"uuid": sample.uuid.uuidString,
|
|
379
|
+
"type": "HKAudiogramSampleTypeIdentifier",
|
|
380
|
+
"startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
|
|
381
|
+
"endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
|
|
382
|
+
"sensitivityPoints": points
|
|
383
|
+
]
|
|
384
|
+
record["sourceName"] = sample.sourceRevision.source.name
|
|
385
|
+
record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
|
|
386
|
+
record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
|
|
387
|
+
return record
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private func mapActivitySummary(_ summary: HKActivitySummary) -> [String: Any] {
|
|
391
|
+
let energy = HKUnit.kilocalorie()
|
|
392
|
+
let minute = HKUnit.minute()
|
|
393
|
+
let count = HKUnit.count()
|
|
394
|
+
let components = summary.dateComponents(for: Calendar.current)
|
|
395
|
+
let date = String(
|
|
396
|
+
format: "%04d-%02d-%02d",
|
|
397
|
+
components.year ?? 0,
|
|
398
|
+
components.month ?? 0,
|
|
399
|
+
components.day ?? 0
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
var record: [String: Any] = [
|
|
403
|
+
"date": date,
|
|
404
|
+
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: energy),
|
|
405
|
+
"appleExerciseTime": summary.appleExerciseTime.doubleValue(for: minute),
|
|
406
|
+
"appleStandHours": summary.appleStandHours.doubleValue(for: count),
|
|
407
|
+
"activeEnergyBurnedGoal": summary.activeEnergyBurnedGoal.doubleValue(for: energy),
|
|
408
|
+
"appleExerciseTimeGoal": summary.appleExerciseTimeGoal.doubleValue(for: minute),
|
|
409
|
+
"appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: count)
|
|
410
|
+
]
|
|
411
|
+
record["appleMoveTime"] = summary.appleMoveTime.doubleValue(for: minute)
|
|
412
|
+
record["appleMoveTimeGoal"] = summary.appleMoveTimeGoal.doubleValue(for: minute)
|
|
413
|
+
return record
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private func mapClinicalRecord(_ sample: HKClinicalRecord) -> [String: Any] {
|
|
417
|
+
var record: [String: Any] = [
|
|
418
|
+
"uuid": sample.uuid.uuidString,
|
|
419
|
+
"type": sample.clinicalType.identifier,
|
|
420
|
+
"startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
|
|
421
|
+
"endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
|
|
422
|
+
"displayName": sample.displayName
|
|
423
|
+
]
|
|
424
|
+
if let fhir = sample.fhirResource {
|
|
425
|
+
record["fhirResourceType"] = fhir.resourceType.rawValue
|
|
426
|
+
record["fhirJson"] = String(data: fhir.data, encoding: .utf8)
|
|
427
|
+
}
|
|
428
|
+
record["sourceName"] = sample.sourceRevision.source.name
|
|
429
|
+
record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
|
|
430
|
+
return record
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private func mapWorkoutRoute(_ route: HKWorkoutRoute, locations: [CLLocation]) -> [String: Any] {
|
|
434
|
+
[
|
|
435
|
+
"uuid": route.uuid.uuidString,
|
|
436
|
+
"type": "HKWorkoutRouteTypeIdentifier",
|
|
437
|
+
"startDate": HealthKitIdentifiers.isoString(from: route.startDate),
|
|
438
|
+
"endDate": HealthKitIdentifiers.isoString(from: route.endDate),
|
|
439
|
+
"locations": locations.map { location -> [String: Any] in
|
|
440
|
+
var point: [String: Any] = [
|
|
441
|
+
"latitude": location.coordinate.latitude,
|
|
442
|
+
"longitude": location.coordinate.longitude,
|
|
443
|
+
"timestamp": HealthKitIdentifiers.isoString(from: location.timestamp)
|
|
444
|
+
]
|
|
445
|
+
if location.verticalAccuracy >= 0 {
|
|
446
|
+
point["altitude"] = location.altitude
|
|
447
|
+
}
|
|
448
|
+
if location.speed >= 0 {
|
|
449
|
+
point["speed"] = location.speed
|
|
450
|
+
}
|
|
451
|
+
if location.course >= 0 {
|
|
452
|
+
point["course"] = location.course
|
|
453
|
+
}
|
|
454
|
+
return point
|
|
455
|
+
}
|
|
456
|
+
]
|
|
457
|
+
}
|
|
458
|
+
}
|