HealthKitService.swift 11.8 KB
import Foundation
import HealthKit

/// Native Apple Health service for the Flutter host API.
///
/// Responsibilities:
/// - request/read HealthKit permissions
/// - read the health data types used by the original SwiftUI app
/// - keep Watch complication values fresh in the shared App Group
/// - register background observers so HealthKit changes refresh local state
final class HealthKitService {
  static let shared = HealthKitService()

  private let healthStore = HKHealthStore()
  private let syncStore = HealthSyncStateStore()
  private lazy var reader = HealthDataReader(healthStore: healthStore)
  private var observersStarted = false
  private var observerQueries: [HKObserverQuery] = []

  private init() {}

  var isHealthDataAvailable: Bool {
    HKHealthStore.isHealthDataAvailable()
  }

  func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) {
    guard isHealthDataAvailable else {
      completion(false, NativeHealthKitError.healthDataUnavailable)
      return
    }

    healthStore.requestAuthorization(
      toShare: [],
      read: NativeHealthTypeCatalog.readTypes
    ) { success, error in
      completion(success, error)
    }
  }

  func authorizationRequestStatus() async -> HKAuthorizationRequestStatus? {
    do {
      return try await healthStore.statusForAuthorizationRequest(
        toShare: [],
        read: NativeHealthTypeCatalog.readTypes
      )
    } catch {
      return nil
    }
  }

  func shouldRequestAuthorization() async -> Bool {
    guard isHealthDataAvailable else { return false }
    return await authorizationRequestStatus() != .unnecessary
  }

  func hasAnyReadableData(startingAt requestedStartDate: Date? = nil) async -> Bool {
    guard isHealthDataAvailable else { return false }

    let endDate = Date()
    let startDate = requestedStartDate
      ?? Calendar.current.date(byAdding: .year, value: -2, to: endDate)
      ?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)

    let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
    if await hasAnyReadableSample(
      of: sampleTypes,
      startDate: startDate,
      endDate: endDate
    ) {
      return true
    }

    do {
      return try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate) != nil
    } catch {
      return false
    }
  }

  private func hasAnyReadableSample(
    of sampleTypes: [HKSampleType],
    startDate: Date,
    endDate: Date
  ) async -> Bool {
    guard !sampleTypes.isEmpty else { return false }

    let predicate = HKQuery.predicateForSamples(
      withStart: startDate,
      end: endDate,
      options: []
    )
    let state = HealthReadableDataProbeState()

    return await withCheckedContinuation { continuation in
      let group = DispatchGroup()
      for sampleType in sampleTypes {
        group.enter()
        let query = HKSampleQuery(
          sampleType: sampleType,
          predicate: predicate,
          limit: 1,
          sortDescriptors: nil
        ) { _, samples, _ in
          if samples?.isEmpty == false {
            state.markDataFound()
          }
          group.leave()
        }
        healthStore.execute(query)
      }

      group.notify(queue: .global(qos: .utility)) {
        continuation.resume(returning: state.hasData)
      }
    }
  }

  func startBackgroundObserversIfNeeded() {
    guard isHealthDataAvailable else { return }
    // Enabling background delivery is safe to repeat and must be retried after
    // authorization or a transient system failure.
    NativeHealthTypeCatalog.observedTypes.forEach(enableBackgroundDelivery)
    guard !observersStarted else { return }
    observersStarted = true

    for sampleType in NativeHealthTypeCatalog.observedTypes {
      let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in
        guard error == nil else {
          completion()
          return
        }
        Task {
          await self?.handleObservedChange(sampleType)
          completion()
        }
      }
      observerQueries.append(query)
      healthStore.execute(query)
    }
  }

  func performLocalSync() async -> NativeHealthSyncSummary {
    guard isHealthDataAvailable else {
      return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: Date(), endedAt: Date())
    }

    let startDate = earliestStartDate()
    let endDate = Date()

    do {
      let summary = try await reader.collectRecentData(startDate: startDate, endDate: endDate)
      NativeHealthDataType.allCases
        .filter { $0 != .unknown }
        .forEach { syncStore.save(date: endDate, for: $0) }
      await refreshSharedWatchValues()
      startBackgroundObserversIfNeeded()
      return summary
    } catch {
      await refreshSharedWatchValues()
      return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: startDate, endedAt: endDate)
    }
  }

  func refreshSharedWatchValues() async {
      _ = WatchConnectivityService.shared.sendCommandMessage(AppGroupMessageKey.statusPulseRefresh)
  }

  func fetchHrvData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchHrvData(startDate: startDate, endDate: endDate)
  }

  func fetchHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchWalkingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchWalkingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchRestingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchRestingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepingHeartRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchSleepingHeartRateData(startDate: startDate, endDate: endDate)
  }

  func fetchOxygenSaturationData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchOxygenSaturationData(startDate: startDate, endDate: endDate)
  }

  func fetchActiveEnergyData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
  }

  func fetchExerciseData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchExerciseData(startDate: startDate, endDate: endDate)
  }

  func fetchStandData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchStandData(startDate: startDate, endDate: endDate)
  }

  func fetchStepCountData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchStepCountData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepData(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
    try await reader.fetchSleepData(startDate: startDate, endDate: endDate)
  }

  func fetchSleepingWristTemperatureData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchSleepingWristTemperatureData(startDate: startDate, endDate: endDate)
  }

  func fetchRespiratoryRateData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchRespiratoryRateData(startDate: startDate, endDate: endDate)
  }

  func fetchIrregularHeartRhythmData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
    try await reader.fetchIrregularHeartRhythmData(startDate: startDate, endDate: endDate)
  }

  func fetchActivityTargetData(startDate: Date, endDate: Date) async throws -> NativeActivityTarget? {
    try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate)
  }

  private func earliestStartDate() -> Date {
    NativeHealthDataType.allCases
      .filter { $0 != .unknown }
      .map { syncStore.startDate(for: $0) }
      .min() ?? Calendar.current.startOfDay(for: Date())
  }

  private func enableBackgroundDelivery(for sampleType: HKSampleType) {
    let frequency: HKUpdateFrequency = sampleType.identifier == HKQuantityTypeIdentifier.stepCount.rawValue
      ? .hourly
      : .immediate

    healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in
      if let error {
        print("HealthKit background delivery failed: \(sampleType.identifier), \(error.localizedDescription)")
      } else {
        print("HealthKit background delivery \(success ? "enabled" : "not enabled"): \(sampleType.identifier)")
      }
    }
  }

  private func handleObservedChange(_ sampleType: HKSampleType) async {
    switch sampleType.identifier {
    case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue,
         HKQuantityTypeIdentifier.stepCount.rawValue:
      await refreshSharedWatchValues()
    default:
      break
    }

    let uploadTypes: [NativeHealthDataType]
    let includeActivityTarget: Bool
    switch sampleType.identifier {
    case HKQuantityTypeIdentifier.heartRate.rawValue:
      uploadTypes = [.heartRate, .sleepingHeartRate]
      includeActivityTarget = false
    case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
      uploadTypes = [.sleep, .sleepingHeartRate]
      includeActivityTarget = false
    default:
      uploadTypes = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier).map { [$0] } ?? []
      includeActivityTarget = [
        HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
        HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
        HKQuantityTypeIdentifier.appleStandTime.rawValue,
      ].contains(sampleType.identifier)
    }

    guard !uploadTypes.isEmpty || includeActivityTarget else { return }
    let success = await NativeHealthDataUploader.shared.uploadObservedChange(
      types: uploadTypes,
      includeActivityTarget: includeActivityTarget,
      service: self
    )
    if success {
      uploadTypes.forEach { syncStore.save(date: Date(), for: $0) }
    }
  }

  func uploadActivityTargetAfterForeground() async {
    guard AppShared.shared.token?.isEmpty == false else { return }
    _ = await NativeHealthDataUploader.shared.uploadObservedChange(
      types: [],
      includeActivityTarget: true,
      service: self
    )
  }
}

private final class HealthReadableDataProbeState: @unchecked Sendable {
  private let lock = NSLock()
  private var dataFound = false

  func markDataFound() {
    lock.lock()
    dataFound = true
    lock.unlock()
  }

  var hasData: Bool {
    lock.lock()
    defer { lock.unlock() }
    return dataFound
  }
}

private extension NativeHealthDataType {
  init?(sampleTypeIdentifier: String) {
    switch sampleTypeIdentifier {
    case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:
      self = .hrv
    case HKQuantityTypeIdentifier.heartRate.rawValue:
      self = .heartRate
    case HKQuantityTypeIdentifier.stepCount.rawValue:
      self = .steps
    case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:
      self = .oxygenSaturation
    case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
      self = .activeEnergy
    case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
      self = .exercise
    case HKQuantityTypeIdentifier.appleStandTime.rawValue:
      self = .stand
    case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
      self = .walkingHeartRate
    case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
      self = .restingHeartRate
    case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
      self = .sleepingWristTemperature
    case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
      self = .respiratoryRate
    case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
      self = .sleep
    case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:
      self = .irregularHeartRhythm
    default:
      return nil
    }
  }
}