452 lines
13 KiB
Swift
452 lines
13 KiB
Swift
import Foundation
|
|
import HealthKit
|
|
import NitroModules
|
|
|
|
/// Read-only HealthKit access used as dated physiological context.
|
|
///
|
|
/// This module deliberately uses sample queries rather than observer/live queries. It never
|
|
/// creates heartbeat series and never treats historical HealthKit values as a current reading.
|
|
final class HybridHaptikaHealthKit: HybridHaptikaHealthKitSpec {
|
|
private static let defaultLookbackDays = 30.0
|
|
private static let minimumLookbackDays = 1.0
|
|
private static let maximumLookbackDays = 365.0
|
|
private static let maximumRecentHeartRateSamples = 100
|
|
|
|
private let healthStore: HKHealthStore
|
|
|
|
override init() {
|
|
self.healthStore = HKHealthStore()
|
|
super.init()
|
|
}
|
|
|
|
func isAvailable() throws -> Bool {
|
|
return HKHealthStore.isHealthDataAvailable()
|
|
}
|
|
|
|
func requestReadAuthorization() throws -> Promise<Bool> {
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
return Promise.resolved(withResult: false)
|
|
}
|
|
|
|
let healthStore = self.healthStore
|
|
let readTypes = Self.readTypes()
|
|
|
|
return Promise<Bool>.async {
|
|
do {
|
|
try await healthStore.requestAuthorization(
|
|
toShare: Set<HKSampleType>(),
|
|
read: readTypes
|
|
)
|
|
|
|
// HealthKit intentionally does not reveal whether read access was granted. A successful
|
|
// authorization request only means that the sheet completed without an operational error.
|
|
return true
|
|
} catch {
|
|
// HealthKit is optional; cancellation, a missing entitlement, or a locked/unavailable
|
|
// store must not block the camera measurement flow.
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchContext(lookbackDays: Double) throws -> Promise<NativeHealthKitContext> {
|
|
let fetchedAt = Date()
|
|
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
return Promise.resolved(
|
|
withResult: Self.emptyContext(
|
|
available: false,
|
|
authorizationRequestSettled: false,
|
|
fetchedAt: fetchedAt
|
|
)
|
|
)
|
|
}
|
|
|
|
let healthStore = self.healthStore
|
|
let boundedLookbackDays = Self.boundedLookbackDays(lookbackDays)
|
|
|
|
return Promise<NativeHealthKitContext>.async {
|
|
let authorizationRequestSettled = await Self.authorizationRequestIsSettled(
|
|
healthStore: healthStore
|
|
)
|
|
|
|
// Do not query before the optional permission flow has been presented. When access has been
|
|
// denied, HealthKit protects the decision by returning no samples, just as it does when the
|
|
// store genuinely has no samples.
|
|
guard authorizationRequestSettled else {
|
|
return Self.emptyContext(
|
|
available: true,
|
|
authorizationRequestSettled: false,
|
|
fetchedAt: fetchedAt
|
|
)
|
|
}
|
|
|
|
let startDate = fetchedAt.addingTimeInterval(-boundedLookbackDays * 86_400.0)
|
|
|
|
async let restingSamples = Self.quantitySamplesSafely(
|
|
healthStore: healthStore,
|
|
type: Self.quantityType(.restingHeartRate),
|
|
startDate: startDate,
|
|
endDate: fetchedAt,
|
|
limit: 1
|
|
)
|
|
async let hrvSamples = Self.quantitySamplesSafely(
|
|
healthStore: healthStore,
|
|
type: Self.quantityType(.heartRateVariabilitySDNN),
|
|
startDate: startDate,
|
|
endDate: fetchedAt,
|
|
limit: 1
|
|
)
|
|
async let heartRateSamples = Self.quantitySamplesSafely(
|
|
healthStore: healthStore,
|
|
type: Self.quantityType(.heartRate),
|
|
startDate: startDate,
|
|
endDate: fetchedAt,
|
|
limit: Self.maximumRecentHeartRateSamples
|
|
)
|
|
|
|
let (resting, hrv, heartRates) = await (
|
|
restingSamples,
|
|
hrvSamples,
|
|
heartRateSamples
|
|
)
|
|
|
|
let latestResting = resting.first
|
|
let latestHrv = hrv.first
|
|
|
|
let nativeResting = latestResting.flatMap {
|
|
Self.makeNativeQuantitySample(
|
|
$0,
|
|
metricType: .restingheartrate,
|
|
unit: Self.beatsPerMinuteUnit,
|
|
unitLabel: "count/min"
|
|
)
|
|
}
|
|
let nativeHrv = latestHrv.flatMap {
|
|
Self.makeNativeQuantitySample(
|
|
$0,
|
|
metricType: .hrvsdnn,
|
|
unit: Self.millisecondUnit,
|
|
unitLabel: "ms"
|
|
)
|
|
}
|
|
let nativeHeartRates = heartRates.compactMap {
|
|
Self.makeNativeQuantitySample(
|
|
$0,
|
|
metricType: .heartrate,
|
|
unit: Self.beatsPerMinuteUnit,
|
|
unitLabel: "count/min"
|
|
)
|
|
}
|
|
|
|
return NativeHealthKitContext(
|
|
available: true,
|
|
// This means the authorization request no longer needs presenting. Apple deliberately
|
|
// makes an explicit read denial indistinguishable from an authorized store with no data.
|
|
authorized: authorizationRequestSettled,
|
|
latestRestingHeartRate: nativeResting,
|
|
latestHrvSdnn: nativeHrv,
|
|
recentHeartRateSamples: nativeHeartRates,
|
|
contextConfidence: Self.contextConfidence(
|
|
latestResting: latestResting,
|
|
latestHrv: latestHrv,
|
|
heartRates: heartRates,
|
|
fetchedAt: fetchedAt,
|
|
lookbackDays: boundedLookbackDays
|
|
),
|
|
fetchedAt: Self.iso8601String(from: fetchedAt)
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchLatestHeartbeatSeries() throws -> Promise<NativeHeartbeatSeriesSummary?> {
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
return Promise.resolved(withResult: nil)
|
|
}
|
|
|
|
let healthStore = self.healthStore
|
|
|
|
return Promise<NativeHeartbeatSeriesSummary?>.async {
|
|
guard await Self.authorizationRequestIsSettled(healthStore: healthStore) else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
guard let sample = try await Self.latestHeartbeatSeriesSample(
|
|
healthStore: healthStore
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
var heartbeatTimes: [Double] = []
|
|
var precededByGapIndexes: [Double] = []
|
|
let descriptor = HKHeartbeatSeriesQueryDescriptor(sample)
|
|
|
|
for try await heartbeat in descriptor.results(for: healthStore) {
|
|
let time = heartbeat.timeIntervalSinceStart
|
|
guard time.isFinite, time >= 0 else {
|
|
continue
|
|
}
|
|
|
|
heartbeatTimes.append(time)
|
|
if heartbeat.precededByGap {
|
|
precededByGapIndexes.append(Double(heartbeatTimes.count - 1))
|
|
}
|
|
}
|
|
|
|
guard !heartbeatTimes.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let source = sample.sourceRevision.source
|
|
return NativeHeartbeatSeriesSummary(
|
|
startDate: Self.iso8601String(from: sample.startDate),
|
|
endDate: Self.iso8601String(from: sample.endDate),
|
|
sourceName: Self.nonEmpty(source.name),
|
|
sourceBundleIdentifier: Self.nonEmpty(source.bundleIdentifier),
|
|
heartbeatTimesSinceSeriesStartSeconds: heartbeatTimes,
|
|
precededByGapIndexes: precededByGapIndexes
|
|
)
|
|
} catch {
|
|
// A read denial, no accessible series, protected-data lock, or query failure all degrade to
|
|
// an absent optional context. None of them should affect the camera PPG measurement.
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private static var beatsPerMinuteUnit: HKUnit {
|
|
return HKUnit.count().unitDivided(by: .minute())
|
|
}
|
|
|
|
private static var millisecondUnit: HKUnit {
|
|
return HKUnit.secondUnit(with: .milli)
|
|
}
|
|
|
|
private static func quantityType(
|
|
_ identifier: HKQuantityTypeIdentifier
|
|
) -> HKQuantityType? {
|
|
return HKObjectType.quantityType(forIdentifier: identifier)
|
|
}
|
|
|
|
private static func readTypes() -> Set<HKObjectType> {
|
|
var types = Set<HKObjectType>()
|
|
|
|
if let heartRate = quantityType(.heartRate) {
|
|
types.insert(heartRate)
|
|
}
|
|
if let restingHeartRate = quantityType(.restingHeartRate) {
|
|
types.insert(restingHeartRate)
|
|
}
|
|
if let hrvSdnn = quantityType(.heartRateVariabilitySDNN) {
|
|
types.insert(hrvSdnn)
|
|
}
|
|
|
|
// Read-only access to series already present in HealthKit. No builder or write type is used.
|
|
types.insert(HKSeriesType.heartbeat())
|
|
return types
|
|
}
|
|
|
|
private static func authorizationRequestIsSettled(
|
|
healthStore: HKHealthStore
|
|
) async -> Bool {
|
|
do {
|
|
let status = try await healthStore.statusForAuthorizationRequest(
|
|
toShare: Set<HKSampleType>(),
|
|
read: readTypes()
|
|
)
|
|
return status == .unnecessary
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func quantitySamplesSafely(
|
|
healthStore: HKHealthStore,
|
|
type: HKQuantityType?,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
limit: Int
|
|
) async -> [HKQuantitySample] {
|
|
guard let type else {
|
|
return []
|
|
}
|
|
|
|
do {
|
|
return try await quantitySamples(
|
|
healthStore: healthStore,
|
|
type: type,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
limit: limit
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
private static func quantitySamples(
|
|
healthStore: HKHealthStore,
|
|
type: HKQuantityType,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
limit: Int
|
|
) async throws -> [HKQuantitySample] {
|
|
let predicate = HKQuery.predicateForSamples(
|
|
withStart: startDate,
|
|
end: endDate,
|
|
options: [.strictEndDate]
|
|
)
|
|
let sort = NSSortDescriptor(
|
|
key: HKSampleSortIdentifierEndDate,
|
|
ascending: false
|
|
)
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
let query = HKSampleQuery(
|
|
sampleType: type,
|
|
predicate: predicate,
|
|
limit: limit,
|
|
sortDescriptors: [sort]
|
|
) { _, samples, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
|
|
continuation.resume(
|
|
returning: samples?.compactMap { $0 as? HKQuantitySample } ?? []
|
|
)
|
|
}
|
|
|
|
healthStore.execute(query)
|
|
}
|
|
}
|
|
|
|
private static func latestHeartbeatSeriesSample(
|
|
healthStore: HKHealthStore
|
|
) async throws -> HKHeartbeatSeriesSample? {
|
|
let sort = NSSortDescriptor(
|
|
key: HKSampleSortIdentifierEndDate,
|
|
ascending: false
|
|
)
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
let query = HKSampleQuery(
|
|
sampleType: HKSeriesType.heartbeat(),
|
|
predicate: nil,
|
|
limit: 1,
|
|
sortDescriptors: [sort]
|
|
) { _, samples, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
|
|
continuation.resume(
|
|
returning: samples?.first as? HKHeartbeatSeriesSample
|
|
)
|
|
}
|
|
|
|
healthStore.execute(query)
|
|
}
|
|
}
|
|
|
|
private static func makeNativeQuantitySample(
|
|
_ sample: HKQuantitySample,
|
|
metricType: HealthMetricType,
|
|
unit: HKUnit,
|
|
unitLabel: String
|
|
) -> NativeHealthMetricSample? {
|
|
let value = sample.quantity.doubleValue(for: unit)
|
|
guard value.isFinite else {
|
|
return nil
|
|
}
|
|
|
|
let source = sample.sourceRevision.source
|
|
let deviceName = nonEmpty(sample.device?.name) ?? nonEmpty(sample.device?.model)
|
|
|
|
return NativeHealthMetricSample(
|
|
type: metricType,
|
|
value: value,
|
|
unit: unitLabel,
|
|
startDate: iso8601String(from: sample.startDate),
|
|
endDate: iso8601String(from: sample.endDate),
|
|
sourceName: nonEmpty(source.name),
|
|
sourceBundleIdentifier: nonEmpty(source.bundleIdentifier),
|
|
deviceName: deviceName
|
|
)
|
|
}
|
|
|
|
/// A conservative availability-and-recency score for optional context only. It is not a signal
|
|
/// quality score and must not be interpreted as confidence in a stress or medical conclusion.
|
|
private static func contextConfidence(
|
|
latestResting: HKQuantitySample?,
|
|
latestHrv: HKQuantitySample?,
|
|
heartRates: [HKQuantitySample],
|
|
fetchedAt: Date,
|
|
lookbackDays: Double
|
|
) -> Double {
|
|
let horizon = max(lookbackDays * 86_400.0, 1.0)
|
|
|
|
func freshness(of sample: HKSample) -> Double {
|
|
let age = max(fetchedAt.timeIntervalSince(sample.endDate), 0.0)
|
|
return min(max(1.0 - age / horizon, 0.0), 1.0)
|
|
}
|
|
|
|
var score = 0.0
|
|
if let latestResting {
|
|
score += 0.35 * freshness(of: latestResting)
|
|
}
|
|
if let latestHrv {
|
|
score += 0.35 * freshness(of: latestHrv)
|
|
}
|
|
if let latestHeartRate = heartRates.first {
|
|
let sampleCoverage = min(Double(heartRates.count) / 5.0, 1.0)
|
|
score += 0.20 * freshness(of: latestHeartRate)
|
|
score += 0.10 * sampleCoverage
|
|
}
|
|
|
|
return min(max(score, 0.0), 1.0)
|
|
}
|
|
|
|
private static func emptyContext(
|
|
available: Bool,
|
|
authorizationRequestSettled: Bool,
|
|
fetchedAt: Date
|
|
) -> NativeHealthKitContext {
|
|
return NativeHealthKitContext(
|
|
available: available,
|
|
authorized: authorizationRequestSettled,
|
|
latestRestingHeartRate: nil,
|
|
latestHrvSdnn: nil,
|
|
recentHeartRateSamples: [],
|
|
contextConfidence: 0,
|
|
fetchedAt: iso8601String(from: fetchedAt)
|
|
)
|
|
}
|
|
|
|
private static func boundedLookbackDays(_ value: Double) -> Double {
|
|
guard value.isFinite else {
|
|
return defaultLookbackDays
|
|
}
|
|
return min(max(value, minimumLookbackDays), maximumLookbackDays)
|
|
}
|
|
|
|
private static func iso8601String(from date: Date) -> String {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
private static func nonEmpty(_ value: String?) -> String? {
|
|
guard let value else {
|
|
return nil
|
|
}
|
|
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|