HealthAnchoredUploadSupport.swift 4.98 KB
import Foundation
import HealthKit

struct SharedHealthAnchoredChanges {
  let samples: [HKSample]
  let newAnchor: HKQueryAnchor
  let deletedObjectCount: Int
}

enum SharedHealthAnchoredQueryError: LocalizedError {
  case missingAnchor

  var errorDescription: String? { "HealthKit did not return a query anchor." }
}

final class SharedHealthAnchoredQueryReader {
  private let healthStore: HKHealthStore

  init(healthStore: HKHealthStore) {
    self.healthStore = healthStore
  }

  func fetchChanges(
    sampleType: HKSampleType,
    anchor: HKQueryAnchor?,
    initialStartDate: Date?
  ) async throws -> SharedHealthAnchoredChanges {
    let predicate = initialStartDate.map {
      HKQuery.predicateForSamples(withStart: $0, end: Date(), options: [])
    }

    return try await withCheckedThrowingContinuation { continuation in
      let query = HKAnchoredObjectQuery(
        type: sampleType,
        predicate: predicate,
        anchor: anchor,
        limit: HKObjectQueryNoLimit
      ) { _, samples, deletedObjects, newAnchor, error in
        if let error {
          continuation.resume(throwing: error)
        } else if let newAnchor {
          continuation.resume(returning: SharedHealthAnchoredChanges(
            samples: samples ?? [],
            newAnchor: newAnchor,
            deletedObjectCount: deletedObjects?.count ?? 0
          ))
        } else {
          continuation.resume(throwing: SharedHealthAnchoredQueryError.missingAnchor)
        }
      }
      self.healthStore.execute(query)
    }
  }
}

enum SharedHealthAnchoredUploadSupport {
  static func archive(_ anchor: HKQueryAnchor) throws -> Data {
    try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
  }

  static func batches<Element>(_ values: [Element], size: Int) -> [[Element]] {
    guard size > 0 else { return [values] }
    return stride(from: 0, to: values.count, by: size).map {
      Array(values[$0..<Swift.min($0 + size, values.count)])
    }
  }
}

@MainActor
final class SharedHealthAnchoredObserverController {
  private let healthStore: HKHealthStore
  private let observedTypes: Set<HKSampleType>
  private let isLoggedIn: () -> Bool
  private let isAuthorized: () async -> Bool
  private let onChanges: (Set<String>) async -> Void
  private let log: (String) -> Void
  private var observerQuery: HKObserverQuery?
  private var started = false

  init(
    healthStore: HKHealthStore,
    observedTypes: Set<HKSampleType>,
    isLoggedIn: @escaping () -> Bool,
    isAuthorized: @escaping () async -> Bool,
    onChanges: @escaping (Set<String>) async -> Void,
    log: @escaping (String) -> Void
  ) {
    self.healthStore = healthStore
    self.observedTypes = observedTypes
    self.isLoggedIn = isLoggedIn
    self.isAuthorized = isAuthorized
    self.onChanges = onChanges
    self.log = log
  }

  func startIfNeeded() {
    guard HKHealthStore.isHealthDataAvailable(), isLoggedIn() else { return }
    Task { [weak self] in
      guard let self else { return }
      guard await isAuthorized() else {
        log("observer.waitingForAuthorization")
        return
      }
      startAuthorizedIfNeeded()
    }
  }

  func restartAfterAuthorization() {
    stop()
    startIfNeeded()
  }

  func stop() {
    if let observerQuery { healthStore.stop(observerQuery) }
    observerQuery = nil
    started = false
    log("observer.stopped")
  }

  private func startAuthorizedIfNeeded() {
    guard isLoggedIn() else { return }
    observedTypes.forEach(enableBackgroundDelivery)
    guard !started else { return }

    let descriptors = observedTypes.map { HKQueryDescriptor(sampleType: $0, predicate: nil) }
    let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] query, sampleTypes, completion, error in
      Task { @MainActor in
        guard let self else {
          completion()
          return
        }
        if let error {
          self.log("observer.error error=\(error.localizedDescription)")
          self.resetAfterError(query)
          completion()
          return
        }
        let identifiers = Set((sampleTypes ?? []).map(\.identifier))
        self.log("observer.changed sources=\(identifiers.sorted().joined(separator: ","))")
        await self.onChanges(identifiers)
        completion()
      }
    }
    observerQuery = query
    started = true
    healthStore.execute(query)
    log("observer.started count=\(descriptors.count)")
  }

  private func resetAfterError(_ query: HKObserverQuery) {
    guard observerQuery === query else { return }
    healthStore.stop(query)
    observerQuery = nil
    started = false
    log("observer.resetAfterError")
  }

  private func enableBackgroundDelivery(_ sampleType: HKSampleType) {
    healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { [log] success, error in
      if let error {
        log("backgroundDelivery.failed source=\(sampleType.identifier) error=\(error.localizedDescription)")
      } else {
        log("backgroundDelivery source=\(sampleType.identifier) success=\(success)")
      }
    }
  }
}