AnchoredHealthDataUploader.swift 10.5 KB
import Foundation
import HealthKit

struct AnchoredHealthUploadSummary {
  let sleepUploadSuccess: Bool
  let errorMessage: String?
  let sleepCount: Int
}

struct AnchoredHealthUploadAnchorStore {
  static let sleepAnchorKey = "sleep_all"

  private let defaults: UserDefaults
  private let keyPrefix = "health_upload_query_anchor_all_v1"

  init(defaults: UserDefaults? = AppGroupConstants.defaults) {
    self.defaults = defaults ?? .standard
  }

  func anchor(userId: Int, anchorKey: String) -> HKQueryAnchor? {
    guard let data = data(userId: userId, anchorKey: anchorKey) else { return nil }
    return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
  }

  func data(userId: Int, anchorKey: String) -> Data? {
    guard userId > 0 else { return nil }
    return defaults.data(forKey: key(userId: userId, anchorKey: anchorKey))
  }

  func save(_ data: Data, userId: Int, anchorKey: String) {
    guard userId > 0 else { return }
    defaults.set(data, forKey: key(userId: userId, anchorKey: anchorKey))
  }

  private func key(userId: Int, anchorKey: String) -> String {
    "\(keyPrefix).user_\(userId).anchor_\(anchorKey)"
  }
}

actor AnchoredHealthDataUploader {
  static let shared = AnchoredHealthDataUploader()

  private let session: URLSession
  private let reader: AnchoredHealthDataReader
  private let anchorStore: AnchoredHealthUploadAnchorStore
  private let userIdProvider: () -> Int?
  private let uploadBatchSize = 300
  private(set) var isUploadingAll = false
  private var uploadAllPending = false
  private var uploadAllWaiters: [CheckedContinuation<AnchoredHealthUploadSummary, Never>] = []

  init(
    session: URLSession = .shared,
    anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
    userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
  ) {
    self.session = session
    self.anchorStore = anchorStore
    self.userIdProvider = userIdProvider
    self.reader = AnchoredHealthDataReader(anchorStore: anchorStore, userIdProvider: userIdProvider)
  }

  func uploadAll() async -> AnchoredHealthUploadSummary {
    await runUploadAll(queueAnotherRunIfUploading: false)
  }

  func uploadAllAfterObservedChange(sampleTypeIdentifiers: Set<String>) async {
    guard sampleTypeIdentifiers.isEmpty
      || sampleTypeIdentifiers.contains(HKCategoryTypeIdentifier.sleepAnalysis.rawValue) else {
      let sources = sampleTypeIdentifiers.sorted().joined(separator: ",")
      Self.log("uploader.observer.skip reason=nonSleep sources=\(sources)")
      return
    }

    let sources = sampleTypeIdentifiers.isEmpty
      ? "unknown"
      : sampleTypeIdentifiers.sorted().joined(separator: ",")
    Self.log("uploader.observer.received userId=\(userIdProvider() ?? -1) sources=\(sources) dataTypes=sleep")
    _ = await runUploadAll(queueAnotherRunIfUploading: true)
  }

  func uploadAllSleep() async throws -> Int {
    let result = try await reader.readAllSleep()
    Self.log(
      "uploader.sleep.read userId=\(userIdProvider() ?? -1) count=\(result.data.count) range=\(Self.describeSleepRange(result.data)) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(result.anchor))"
    )
    Self.logSleepIntervals(result.data, userId: userIdProvider() ?? -1, prefix: "uploader.sleep.read.item")
    return try await uploadSleep(result.data, anchor: result.anchor)
  }
}

private extension AnchoredHealthDataUploader {
  func runUploadAll(
    queueAnotherRunIfUploading: Bool
  ) async -> AnchoredHealthUploadSummary {
    if isUploadingAll {
      if queueAnotherRunIfUploading {
        uploadAllPending = true
        Self.log("uploader.all.pending userId=\(userIdProvider() ?? -1) dataTypes=sleep")
      } else {
        Self.log("uploader.all.skip reason=alreadyUploading userId=\(userIdProvider() ?? -1)")
      }
      return await withCheckedContinuation { continuation in
        uploadAllWaiters.append(continuation)
      }
    }

    isUploadingAll = true
    var summary: AnchoredHealthUploadSummary
    repeat {
      uploadAllPending = false
      Self.log("uploader.all.start userId=\(userIdProvider() ?? -1) dataTypes=sleep")
      summary = await performUploadAll()
      Self.log(
        "uploader.all.end userId=\(userIdProvider() ?? -1) sleepSuccess=\(summary.sleepUploadSuccess) sleepCount=\(summary.sleepCount) error=\(summary.errorMessage ?? "nil")"
      )
    } while uploadAllPending
    isUploadingAll = false

    let waiters = uploadAllWaiters
    uploadAllWaiters.removeAll()
    waiters.forEach { $0.resume(returning: summary) }
    return summary
  }

  func performUploadAll() async -> AnchoredHealthUploadSummary {
    guard AppShared.shared.token?.isEmpty == false else {
      return AnchoredHealthUploadSummary(
        sleepUploadSuccess: false,
        errorMessage: NativeHealthUploadError.missingAccessToken.localizedDescription,
        sleepCount: 0
      )
    }

    do {
      let sleepCount = try await uploadAllSleep()
      return AnchoredHealthUploadSummary(
        sleepUploadSuccess: true,
        errorMessage: nil,
        sleepCount: sleepCount
      )
    } catch {
      return AnchoredHealthUploadSummary(
        sleepUploadSuccess: false,
        errorMessage: "sleep 上传失败:\(error.localizedDescription)",
        sleepCount: 0
      )
    }
  }

  func uploadSleep(
    _ data: [NativeSleepInterval],
    anchor: Data
  ) async throws -> Int {
    guard let userId = userIdProvider(), userId > 0 else {
      throw NativeHealthUploadError.missingUserId
    }
    guard !data.isEmpty else {
      Self.log(
        "uploader.sleep.empty userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
      )
      anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
      Self.log(
        "uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=emptySleep \(Self.describeAnchorData(anchor))"
      )
      return 0
    }

    Self.log(
      "uploader.sleep.start userId=\(userId) count=\(data.count) range=\(Self.describeSleepRange(data)) batchSize=\(uploadBatchSize) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
    )
    var uploadedCount = 0
    for (batchIndex, batch) in SharedHealthAnchoredUploadSupport.batches(data, size: uploadBatchSize).enumerated() {
      let list = batch.map {
        [
          "data_type": $0.dataType,
          "from_time": $0.fromTime,
          "to_time": $0.toTime,
        ] as [String: Any]
      }
      try await request(
        path: "/client/doublefeel/health/v2/data_upload/sleep/",
        method: "POST",
        body: ["data_list": list]
      )

      uploadedCount += batch.count
      Self.log(
        "uploader.sleep.batch.success userId=\(userId) batch=\(batchIndex + 1) count=\(batch.count) range=\(Self.describeSleepRange(batch)) uploadedCount=\(uploadedCount)/\(data.count)"
      )
      if uploadedCount == data.count {
        anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
        Self.log(
          "uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=sleepFinished \(Self.describeAnchorData(anchor)) uploadedRange=\(Self.describeSleepRange(data))"
        )
      }
      Self.logSleepIntervals(batch, userId: userId, prefix: "uploader.sleep.batch.success.item")
    }

    return uploadedCount
  }

  @discardableResult
  func request(path: String, method: String, body: [String: Any]? = nil) async throws -> Data {
    guard let baseURL = URL(string: AppShared.shared.baseUrl),
          let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
      throw NativeHealthUploadError.invalidServerURL
    }
    guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else {
      throw NativeHealthUploadError.missingAccessToken
    }

    var request = URLRequest(url: url)
    request.httpMethod = method
    request.timeoutInterval = 60
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    request.setValue(accessToken, forHTTPHeaderField: "access_token")
    request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent")

    if let body {
      request.setValue("application/json", forHTTPHeaderField: "Content-Type")
      request.httpBody = try JSONSerialization.data(withJSONObject: body)
    }

    let (data, response) = try await session.data(for: request)
    guard let httpResponse = response as? HTTPURLResponse else {
      throw NativeHealthUploadError.invalidResponse
    }
    guard (200..<300).contains(httpResponse.statusCode) else {
      if httpResponse.statusCode == 401 {
        await MainActor.run {
          AppShared.shared.logout()
        }
      }
      throw NativeHealthUploadError.requestFailed(
        path: path,
        statusCode: httpResponse.statusCode,
        body: String(data: data, encoding: .utf8)
      )
    }
    return data
  }

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

  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 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 {
    "anchor=size:\(data.count),hash:\(data.stableDebugHash)"
  }

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