import Foundation import HealthKit import OpenClawKit enum HealthAuthorization { static let enabledKey = "health.summary.enabled" static var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } static var isEnabled: Bool { self.isAvailable && UserDefaults.standard.bool(forKey: self.enabledKey) } static var readTypes: Set { var types: Set = [HKWorkoutType.workoutType()] if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) } if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { types.insert(sleep) } if let restingHeartRate = HKObjectType.quantityType(forIdentifier: .restingHeartRate) { types.insert(restingHeartRate) } return types } @MainActor static func enable() async throws { guard self.isAvailable else { throw NSError(domain: "Health", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Health data is unavailable on this device.", ]) } try await HKHealthStore().requestAuthorization(toShare: [], read: self.readTypes) // HealthKit intentionally does not reveal read denial. This flag records only // the user's explicit OpenClaw sharing choice, never inferred authorization. UserDefaults.standard.set(true, forKey: self.enabledKey) } static func disable() { UserDefaults.standard.removeObject(forKey: self.enabledKey) } } protocol HealthSummaryServicing: Sendable { func summary(params: OpenClawHealthSummaryParams) async throws -> OpenClawHealthSummaryPayload } actor HealthSummaryService: HealthSummaryServicing { private let healthStore: HKHealthStore init(healthStore: HKHealthStore = HKHealthStore()) { self.healthStore = healthStore } func summary(params: OpenClawHealthSummaryParams) async throws -> OpenClawHealthSummaryPayload { guard HealthAuthorization.isEnabled else { throw NSError(domain: "Health", code: 2, userInfo: [ NSLocalizedDescriptionKey: "HEALTH_ACCESS_DISABLED: enable Apple Health Summaries in OpenClaw Settings", ]) } let now = Date() let range = Self.dateRange(now: now, calendar: .current) let stepCount = try await self.stepCount(in: range) let sleepDuration = try await self.sleepDuration(in: range) let restingHeartRate = try await self.restingHeartRate(in: range) let workouts = try await self.workouts(in: range) let formatter = ISO8601DateFormatter() return OpenClawHealthSummaryPayload( period: params.period, startISO: formatter.string(from: range.start), endISO: formatter.string(from: range.end), timeZoneIdentifier: TimeZone.current.identifier, stepCount: stepCount, sleepDurationMinutes: sleepDuration.map(Self.roundedMinutes), restingHeartRateBpm: restingHeartRate.map { ($0 * 10).rounded() / 10 }, workoutCount: workouts?.count, workoutDurationMinutes: workouts.map { Self.roundedMinutes($0.duration) }) } static func dateRange( now: Date, calendar: Calendar) -> DateInterval { let startOfToday = calendar.startOfDay(for: now) return DateInterval(start: startOfToday, end: now) } static func mergedDuration( intervals: [DateInterval], clippedTo range: DateInterval) -> TimeInterval? { let clipped = intervals.compactMap { interval -> DateInterval? in let start = max(interval.start, range.start) let end = min(interval.end, range.end) return end > start ? DateInterval(start: start, end: end) : nil }.sorted { $0.start < $1.start } guard var current = clipped.first else { return nil } var duration: TimeInterval = 0 for interval in clipped.dropFirst() { if interval.start <= current.end { current = DateInterval(start: current.start, end: max(current.end, interval.end)) } else { duration += current.duration current = interval } } return duration + current.duration } private static func roundedMinutes(_ seconds: TimeInterval) -> Int { Int((seconds / 60).rounded()) } private func stepCount(in range: DateInterval) async throws -> Int? { guard let type = HKObjectType.quantityType(forIdentifier: .stepCount) else { return nil } let predicate = Self.quantityPredicate(type: type, range: range) let statistics = try await HKStatisticsQueryDescriptor( predicate: predicate, options: .cumulativeSum).result(for: self.healthStore) return statistics?.sumQuantity().map { Int($0.doubleValue(for: .count()).rounded()) } } private func restingHeartRate(in range: DateInterval) async throws -> Double? { guard let type = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { return nil } let predicate = Self.quantityPredicate(type: type, range: range) let statistics = try await HKStatisticsQueryDescriptor( predicate: predicate, options: .discreteAverage).result(for: self.healthStore) let beatsPerMinute = HKUnit.count().unitDivided(by: .minute()) return statistics?.averageQuantity()?.doubleValue(for: beatsPerMinute) } private func sleepDuration(in range: DateInterval) async throws -> TimeInterval? { guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { return nil } let predicate = HKQuery.predicateForSamples( withStart: range.start, end: range.end, options: []) let descriptor = HKSampleQueryDescriptor( predicates: [.categorySample(type: type, predicate: predicate)], sortDescriptors: [], limit: nil) let samples = try await descriptor.result(for: self.healthStore) let asleepIntervals = samples.compactMap { sample -> DateInterval? in guard let value = HKCategoryValueSleepAnalysis(rawValue: sample.value), HKCategoryValueSleepAnalysis.allAsleepValues.contains(value) else { return nil } return DateInterval(start: sample.startDate, end: sample.endDate) } // Sleep stages and sources can overlap. Merge intervals so a minute is // never counted twice in the aggregate sent off device. return Self.mergedDuration(intervals: asleepIntervals, clippedTo: range) } private func workouts(in range: DateInterval) async throws -> (count: Int, duration: TimeInterval)? { let predicate = HKQuery.predicateForSamples( withStart: range.start, end: range.end, options: .strictStartDate) let descriptor = HKSampleQueryDescriptor( predicates: [.workout(predicate)], sortDescriptors: [], limit: nil) let samples = try await descriptor.result(for: self.healthStore) guard !samples.isEmpty else { return nil } let duration = samples.reduce(0) { $0 + $1.duration } return (samples.count, duration) } private static func quantityPredicate( type: HKQuantityType, range: DateInterval) -> HKSamplePredicate { let predicate = HKQuery.predicateForSamples( withStart: range.start, end: range.end, options: []) return .quantitySample(type: type, predicate: predicate) } }