HealthKitService.swift 6.59 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)

    if let sleepType = NativeHealthTypeCatalog.category(.sleepAnalysis),
       await hasAnyReadableSample(
      of: [sleepType],
      startDate: startDate,
      endDate: endDate
    ) {
      return true
    }
    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 fetchSleepData(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
    return try await reader.fetchSleepData(startDate: startDate, endDate: endDate)
  }

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

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

  func fetchRawData(
    for dataType: NativeHealthDataType,
    startDate: Date,
    endDate: Date
  ) async throws -> [NativeHealthRawDataPoint] {
    return try await reader.fetchRawData(for: dataType, startDate: startDate, endDate: endDate)
  }


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

  func fetchAnchoredChanges(
    anchor: HKQueryAnchor?,
    initialStartDate: Date
  ) async throws -> AnchoredChanges {
    guard let sampleType = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
      throw NativeHealthKitError.invalidType(HKCategoryTypeIdentifier.sleepAnalysis.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 handleObservedChanges(sampleTypeIdentifiers: Set<String>) async {
    guard AppShared.shared.isLogin else { return }
    let dataTypes = NativeHealthDataType.mappedTypes(for: sampleTypeIdentifiers)
      .map(\.rawValue)
      .sorted()
    await MainActor.run {
      NotificationCenter.default.post(
        name: .nativeHealthDataDidUpdate,
        object: nil,
        userInfo: ["dataTypes": dataTypes]
      )
    }
    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
  }
}