feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user