Files
haptika/packages/react-native-haptika-ppg/ios/Health/HybridHaptikaHealthKit.swift
2026-07-31 16:20:10 +02:00

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
}
}