HealthKitService.swift 10.5 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
/// - register background observers so HealthKit changes refresh local state
final class HealthKitService {
  static let shared = HealthKitService()

  private let healthStore = HKHealthStore()
  private lazy var reader = HealthKitQueryReader(healthStore: healthStore)
  private lazy var anchoredReader = SharedHealthAnchoredQueryReader(healthStore: healthStore)
  @MainActor private lazy var observerController = SharedHealthAnchoredObserverController(
    healthStore: healthStore,
    observedTypes: NativeHealthTypeCatalog.observedTypes,
    isLoggedIn: { AppShared.shared.isLogin },
    isAuthorized: { [weak self] in
      await self?.authorizationRequestStatus() == .unnecessary
    },
    onChanges: { [weak self] identifiers in
      await self?.handleObservedChanges(sampleTypeIdentifiers: identifiers)
    },
    log: { DebugLogger.debugLog("[ArchUploader] \($0)") }
  )

  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: -1, to: endDate)
      ?? Date(timeInterval: -1 * 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() {
    Task { @MainActor [weak self] in
      self?.observerController.startIfNeeded()
    }
  }

  func restartBackgroundObserversAfterAuthorization() {
    Task { @MainActor [weak self] in
      self?.observerController.restartAfterAuthorization()
    }
  }

  func stopBackgroundObservers() {
    Task { @MainActor [weak self] in
      self?.observerController.stop()
    }
  }

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

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

  struct AnchoredChanges {
    let samples: [HKSample]
    let deletedObjectCount: Int
    let newAnchor: HKQueryAnchor
    let sourceIdentifier: String
  }

  func fetchAnchoredChanges(
    for dataType: NativeHealthDataType,
    sourceIdentifier overrideIdentifier: String? = nil,
    anchor: HKQueryAnchor?,
    initialStartDate: Date
  ) async throws -> AnchoredChanges {
    guard let sampleType = anchoredSampleType(for: dataType, overrideIdentifier: overrideIdentifier) else {
      throw NativeHealthKitError.invalidType("anchor source for \(dataType.rawValue)")
    }
    let changes = try await anchoredReader.fetchChanges(
      sampleType: sampleType,
      anchor: anchor,
      initialStartDate: initialStartDate
    )
    return AnchoredChanges(
      samples: changes.samples,
      deletedObjectCount: changes.deletedObjectCount,
      newAnchor: changes.newAnchor,
      sourceIdentifier: sampleType.identifier
    )
  }

  private func anchoredSampleType(
    for dataType: NativeHealthDataType,
    overrideIdentifier: String?
  ) -> HKSampleType? {
    if let overrideIdentifier {
      if overrideIdentifier == HKCategoryTypeIdentifier.sleepAnalysis.rawValue {
        return NativeHealthTypeCatalog.category(.sleepAnalysis)
      }
      if overrideIdentifier == HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue {
        return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
      }
      return NativeHealthTypeCatalog.quantity(HKQuantityTypeIdentifier(rawValue: overrideIdentifier))
    }

    switch dataType {
    case .hrv: return NativeHealthTypeCatalog.quantity(.heartRateVariabilitySDNN)
    case .heartRate, .sleepingHeartRate: return NativeHealthTypeCatalog.quantity(.heartRate)
    case .walkingHeartRate: return NativeHealthTypeCatalog.quantity(.walkingHeartRateAverage)
    case .restingHeartRate: return NativeHealthTypeCatalog.quantity(.restingHeartRate)
    case .oxygenSaturation: return NativeHealthTypeCatalog.quantity(.oxygenSaturation)
    case .activeEnergy: return NativeHealthTypeCatalog.quantity(.activeEnergyBurned)
    case .exercise: return NativeHealthTypeCatalog.quantity(.appleExerciseTime)
    case .stand: return NativeHealthTypeCatalog.quantity(.appleStandTime)
    case .steps: return NativeHealthTypeCatalog.quantity(.stepCount)
    case .sleepingWristTemperature: return NativeHealthTypeCatalog.quantity(.appleSleepingWristTemperature)
    case .respiratoryRate: return NativeHealthTypeCatalog.quantity(.respiratoryRate)
    case .irregularHeartRhythm: return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
    case .sleep: return NativeHealthTypeCatalog.category(.sleepAnalysis)
    case .unknown: return nil
    }
  }

  private func handleObservedChanges(sampleTypeIdentifiers: Set<String>) async {
    guard AppShared.shared.isLogin else { return }
    await AnchoredHealthDataUploader.shared.uploadAllAfterObservedChange(
      sampleTypeIdentifiers: sampleTypeIdentifiers
    )
  }

  func uploadHealthDataAfterForeground() async {
    guard AppShared.shared.token?.isEmpty == false else { return }
    _ = await AnchoredHealthDataUploader.shared.uploadAll()
  }
}

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