AnchoredHealthDataReader.swift 5.74 KB
import Foundation
import HealthKit

struct AnchoredHealthSleepReadResult {
  let data: [NativeSleepInterval]
  let anchor: Data
}

/// Reads HealthKit sleep changes with HKQueryAnchor and converts them to the
/// upload payload model used by the sleep uploader.
final class AnchoredHealthDataReader {
  private let service: HealthKitService
  private let anchorStore: AnchoredHealthUploadAnchorStore
  private let userIdProvider: () -> Int?

  init(
    service: HealthKitService = .shared,
    anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
    userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
  ) {
    self.service = service
    self.anchorStore = anchorStore
    self.userIdProvider = userIdProvider
  }

  func readAllSleep() async throws -> AnchoredHealthSleepReadResult {
    guard let userId = userIdProvider(), userId > 0 else {
      throw NativeHealthUploadError.missingUserId
    }

    let sleepAnchorKey = AnchoredHealthUploadAnchorStore.sleepAnchorKey
    let storedAnchorData = anchorStore.data(userId: userId, anchorKey: sleepAnchorKey)
    Self.log(
      "reader.sleep.anchor.before userId=\(userId) anchorKey=\(sleepAnchorKey) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(firstUploadStartDate().timeIntervalSince1970))"
    )

    let changes = try await service.fetchAnchoredChanges(
      anchor: anchorStore.anchor(userId: userId, anchorKey: sleepAnchorKey),
      initialStartDate: firstUploadStartDate()
    )
    let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
    Self.log(
      "reader.sleep.anchor.query userId=\(userId) anchorKey=\(sleepAnchorKey) source=\(changes.sourceIdentifier) deleted=\(changes.deletedObjectCount) samples=\(changes.samples.count) sampleRange=\(Self.describeSamples(changes.samples)) newAnchor=\(Self.describeAnchorData(archivedAnchor))"
    )

    guard !changes.samples.isEmpty else {
      Self.log("reader.sleep.all userId=\(userId) count=0 range=empty")
      return AnchoredHealthSleepReadResult(data: [], anchor: archivedAnchor)
    }

    let startDate = queryStartDate(for: changes.samples)
    let endDate = Date()
    Self.log(
      "reader.sleep.fetchRange userId=\(userId) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
    )
    let intervals = try await service.fetchSleepData(startDate: startDate, endDate: endDate)
    let sortedIntervals = Self.deduplicateSleep(intervals).sorted {
      if $0.toTime == $1.toTime {
        return $0.fromTime < $1.fromTime
      }
      return $0.toTime < $1.toTime
    }
    Self.log(
      "reader.sleep.all userId=\(userId) count=\(sortedIntervals.count) range=\(Self.describeSleepRange(sortedIntervals))"
    )
    Self.logSleepIntervals(sortedIntervals, userId: userId, prefix: "reader.sleep.data.item")
    return AnchoredHealthSleepReadResult(data: sortedIntervals, anchor: archivedAnchor)
  }
}

private extension AnchoredHealthDataReader {
  func firstUploadStartDate() -> Date {
    let years = NativeHealthUploadConfiguration.firstUploadLookbackYears
    let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())
      ?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))
    return Calendar.current.startOfDay(for: date)
  }

  func queryStartDate(for samples: [HKSample]) -> Date {
    let earliest = samples.map(\.startDate).min() ?? firstUploadStartDate()
    return max(Calendar.current.startOfDay(for: earliest), firstUploadStartDate())
  }

  static func deduplicateSleep(_ data: [NativeSleepInterval]) -> [NativeSleepInterval] {
    var seen = Set<String>()
    return data.filter { interval in
      let key = "\(interval.dataType)-\(interval.fromTime)-\(interval.toTime)"
      return seen.insert(key).inserted
    }
  }

  static func log(_ message: String) {
    DebugLogger.debugLog("[ArchUploader] \(message)")
  }

  static func logSleepIntervals(
    _ intervals: [NativeSleepInterval],
    userId: Int,
    prefix: String
  ) {
    intervals.forEach { interval in
      log(
        "\(prefix) userId=\(userId) dataType=\(interval.dataType) from=\(debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
      )
    }
  }

  static func describeSamples(_ samples: [HKSample]) -> String {
    guard !samples.isEmpty else { return "empty" }
    let minStart = samples.map(\.startDate).min() ?? .distantPast
    let maxEnd = samples.map(\.endDate).max() ?? .distantPast
    return "\(debugTimestamp(minStart.timeIntervalSince1970))...\(debugTimestamp(maxEnd.timeIntervalSince1970))"
  }

  static func describeSleepRange(_ intervals: [NativeSleepInterval]) -> String {
    guard let minTime = intervals.map(\.fromTime).min(),
          let maxTime = intervals.map(\.toTime).max() else {
      return "empty"
    }
    return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  }

  static func describeAnchorData(_ data: Data?) -> String {
    guard let data else { return "anchor=none" }
    return "anchor=size:\(data.count),hash:\(data.stableDebugHash)"
  }

  static func debugTimestamp(_ timeInterval: TimeInterval) -> String {
    debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
  }

  static let debugDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
    return formatter
  }()
}

private extension Data {
  var stableDebugHash: String {
    let hash = reduce(UInt64(14_695_981_039_346_656_037)) { result, byte in
      (result ^ UInt64(byte)).multipliedReportingOverflow(by: 1_099_511_628_211).partialValue
    }
    return String(hash, radix: 16)
  }
}