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 { guard HKHealthStore.isHealthDataAvailable() else { return Promise.resolved(withResult: false) } let healthStore = self.healthStore let readTypes = Self.readTypes() return Promise.async { do { try await healthStore.requestAuthorization( toShare: Set(), 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 { 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.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 { guard HKHealthStore.isHealthDataAvailable() else { return Promise.resolved(withResult: nil) } let healthStore = self.healthStore return Promise.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 { var types = Set() 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(), 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 } }