Commit 94ddfe45f4ae829634f7d9a10ede629b7b210acf

Authored by 权海
1 parent 0493bd04

feat(ui):修改runner上传数据,修改创作者协议

  1 +import Foundation
  2 +import HealthKit
  3 +
  4 +struct AnchoredHealthCommonReadResult {
  5 + let data: [NativeHealthDataPoint]
  6 + let anchors: [NativeHealthDataType: Data]
  7 +}
  8 +
  9 +struct AnchoredHealthSleepReadResult {
  10 + let data: [NativeSleepInterval]
  11 + let anchor: Data
  12 +}
  13 +
  14 +struct AnchoredHealthActivityAnchorBundle: Codable {
  15 + let activeEnergy: Data
  16 + let exercise: Data
  17 + let stand: Data
  18 +}
  19 +
  20 +struct AnchoredHealthActivityReadResult {
  21 + let data: [NativeHealthDataPoint]
  22 + let targets: [NativeActivityTarget]
  23 + let anchors: AnchoredHealthActivityAnchorBundle
  24 +}
  25 +
  26 +/// Reads HealthKit changes with HKQueryAnchor and converts them to the same
  27 +/// upload payload models used by the existing uploader.
  28 +final class AnchoredHealthDataReader {
  29 + private let service: HealthKitService
  30 + private let anchorStore: AnchoredHealthUploadAnchorStore
  31 + private let userIdProvider: () -> Int?
  32 +
  33 + init(
  34 + service: HealthKitService = .shared,
  35 + anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
  36 + userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
  37 + ) {
  38 + self.service = service
  39 + self.anchorStore = anchorStore
  40 + self.userIdProvider = userIdProvider
  41 + }
  42 +
  43 + func readAllCommon() async throws -> AnchoredHealthCommonReadResult {
  44 + try await readCommon(dataTypes: nil)
  45 + }
  46 +
  47 + func readCommon(
  48 + dataTypes requestedTypes: Set<NativeHealthDataType>?
  49 + ) async throws -> AnchoredHealthCommonReadResult {
  50 + guard let userId = userIdProvider(), userId > 0 else {
  51 + throw NativeHealthUploadError.missingUserId
  52 + }
  53 +
  54 + var data: [NativeHealthDataPoint] = []
  55 + var anchors: [NativeHealthDataType: Data] = [:]
  56 + let types = requestedTypes.map { requestedTypes in
  57 + Self.commonDataTypes.filter { requestedTypes.contains($0) }
  58 + } ?? Self.commonDataTypes
  59 +
  60 + for type in types {
  61 + let storedAnchorData = anchorStore.data(userId: userId, dataType: type)
  62 + Self.log(
  63 + "reader.common.anchor.before userId=\(userId) dataType=\(type.rawValue) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(firstUploadStartDate().timeIntervalSince1970))"
  64 + )
  65 + let anchor = anchorStore.anchor(userId: userId, dataType: type)
  66 + let changes = try await service.fetchAnchoredChanges(
  67 + for: type,
  68 + sourceIdentifier: Self.sourceIdentifier(for: type),
  69 + anchor: anchor,
  70 + initialStartDate: firstUploadStartDate()
  71 + )
  72 + let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
  73 + anchors[type] = archivedAnchor
  74 + Self.log(
  75 + "reader.common.anchor.query userId=\(userId) dataType=\(type.rawValue) source=\(changes.sourceIdentifier) deleted=\(changes.deletedObjectCount) samples=\(changes.samples.count) sampleRange=\(Self.describeSamples(changes.samples)) newAnchor=\(Self.describeAnchorData(archivedAnchor))"
  76 + )
  77 +
  78 + guard !changes.samples.isEmpty else { continue }
  79 + let startDate = queryStartDate(for: changes.samples)
  80 + let endDate = Date()
  81 + Self.log(
  82 + "reader.common.fetchRange userId=\(userId) dataType=\(type.rawValue) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
  83 + )
  84 + let points = try await fetchData(type: type, startDate: startDate, endDate: endDate)
  85 + Self.log(
  86 + "reader.common.data userId=\(userId) dataType=\(type.rawValue) count=\(points.count) range=\(Self.describeCommonRange(points))"
  87 + )
  88 + Self.logCommonPoints(points, userId: userId, prefix: "reader.common.data.item")
  89 + data.append(contentsOf: points)
  90 + }
  91 +
  92 + let sortedData = Self.deduplicateCommon(data).sorted { lhs, rhs in
  93 + if lhs.time == rhs.time {
  94 + return lhs.dataType.rawValue < rhs.dataType.rawValue
  95 + }
  96 + return lhs.time < rhs.time
  97 + }
  98 + Self.log(
  99 + "reader.common.all userId=\(userId) count=\(sortedData.count) range=\(Self.describeCommonRange(sortedData))"
  100 + )
  101 + return AnchoredHealthCommonReadResult(
  102 + data: sortedData,
  103 + anchors: anchors
  104 + )
  105 + }
  106 +
  107 + func readAllSeelp() async throws -> AnchoredHealthSleepReadResult {
  108 + try await readAllSleep()
  109 + }
  110 +
  111 + func readActivity() async throws -> AnchoredHealthActivityReadResult {
  112 + guard let userId = userIdProvider(), userId > 0 else {
  113 + throw NativeHealthUploadError.missingUserId
  114 + }
  115 +
  116 + let storedAnchors = anchorStore.activityAnchors(userId: userId)
  117 + let startLimit = firstUploadStartDate()
  118 + let activeEnergyChanges = try await activityAnchoredChanges(
  119 + userId: userId,
  120 + dataType: .activeEnergy,
  121 + storedAnchorData: storedAnchors?.activeEnergy,
  122 + initialStartDate: startLimit
  123 + )
  124 + let exerciseChanges = try await activityAnchoredChanges(
  125 + userId: userId,
  126 + dataType: .exercise,
  127 + storedAnchorData: storedAnchors?.exercise,
  128 + initialStartDate: startLimit
  129 + )
  130 + let standChanges = try await activityAnchoredChanges(
  131 + userId: userId,
  132 + dataType: .stand,
  133 + storedAnchorData: storedAnchors?.stand,
  134 + initialStartDate: startLimit
  135 + )
  136 +
  137 + let newAnchors = AnchoredHealthActivityAnchorBundle(
  138 + activeEnergy: try SharedHealthAnchoredUploadSupport.archive(activeEnergyChanges.newAnchor),
  139 + exercise: try SharedHealthAnchoredUploadSupport.archive(exerciseChanges.newAnchor),
  140 + stand: try SharedHealthAnchoredUploadSupport.archive(standChanges.newAnchor)
  141 + )
  142 + let samples = activeEnergyChanges.samples + exerciseChanges.samples + standChanges.samples
  143 + guard !samples.isEmpty else {
  144 + Self.log(
  145 + "reader.activity.all userId=\(userId) count=0 targetCount=0 range=empty anchors=\(Self.describeActivityAnchors(newAnchors))"
  146 + )
  147 + return AnchoredHealthActivityReadResult(data: [], targets: [], anchors: newAnchors)
  148 + }
  149 +
  150 + let startDate = queryStartDate(for: samples)
  151 + let endDate = Date()
  152 + Self.log(
  153 + "reader.activity.fetchRange userId=\(userId) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
  154 + )
  155 + let points = try await fetchActivityData(startDate: startDate, endDate: endDate)
  156 + let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
  157 + let sortedPoints = Self.deduplicateCommon(points).sorted { lhs, rhs in
  158 + if lhs.time == rhs.time {
  159 + return lhs.dataType.rawValue < rhs.dataType.rawValue
  160 + }
  161 + return lhs.time < rhs.time
  162 + }
  163 + let sortedTargets = targets.sorted {
  164 + ($0.healthValueTimestamp ?? 0) < ($1.healthValueTimestamp ?? 0)
  165 + }
  166 + Self.log(
  167 + "reader.activity.all userId=\(userId) count=\(sortedPoints.count) targetCount=\(sortedTargets.count) range=\(Self.describeCommonRange(sortedPoints)) targetRange=\(Self.describeActivityTargetRange(sortedTargets)) anchors=\(Self.describeActivityAnchors(newAnchors))"
  168 + )
  169 + Self.logCommonPoints(sortedPoints, userId: userId, prefix: "reader.activity.data.item")
  170 + Self.logActivityTargets(sortedTargets, userId: userId, prefix: "reader.activity.target.item")
  171 + return AnchoredHealthActivityReadResult(data: sortedPoints, targets: sortedTargets, anchors: newAnchors)
  172 + }
  173 +
  174 + func readAllSleep() async throws -> AnchoredHealthSleepReadResult {
  175 + guard let userId = userIdProvider(), userId > 0 else {
  176 + throw NativeHealthUploadError.missingUserId
  177 + }
  178 +
  179 + let type = NativeHealthDataType.sleep
  180 + let sleepAnchorKey = AnchoredHealthUploadAnchorStore.sleepAnchorKey
  181 + let storedAnchorData = anchorStore.data(userId: userId, anchorKey: sleepAnchorKey)
  182 + Self.log(
  183 + "reader.sleep.anchor.before userId=\(userId) anchorKey=\(sleepAnchorKey) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(firstUploadStartDate().timeIntervalSince1970))"
  184 + )
  185 + let anchor = anchorStore.anchor(userId: userId, anchorKey: sleepAnchorKey)
  186 + let changes = try await service.fetchAnchoredChanges(
  187 + for: type,
  188 + sourceIdentifier: Self.sourceIdentifier(for: type),
  189 + anchor: anchor,
  190 + initialStartDate: firstUploadStartDate()
  191 + )
  192 +
  193 + let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
  194 + Self.log(
  195 + "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))"
  196 + )
  197 + guard !changes.samples.isEmpty else {
  198 + Self.log("reader.sleep.all userId=\(userId) count=0 range=empty")
  199 + return AnchoredHealthSleepReadResult(data: [], anchor: archivedAnchor)
  200 + }
  201 +
  202 + let startDate = queryStartDate(for: changes.samples)
  203 + let endDate = Date()
  204 + Self.log(
  205 + "reader.sleep.fetchRange userId=\(userId) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
  206 + )
  207 + let intervals = try await service.fetchSleepData(startDate: startDate, endDate: endDate)
  208 +
  209 + let sortedIntervals = Self.deduplicateSleep(intervals).sorted {
  210 + if $0.toTime == $1.toTime {
  211 + return $0.fromTime < $1.fromTime
  212 + }
  213 + return $0.toTime < $1.toTime
  214 + }
  215 + Self.log(
  216 + "reader.sleep.all userId=\(userId) count=\(sortedIntervals.count) range=\(Self.describeSleepRange(sortedIntervals))"
  217 + )
  218 + Self.logSleepIntervals(sortedIntervals, userId: userId, prefix: "reader.sleep.data.item")
  219 + return AnchoredHealthSleepReadResult(
  220 + data: sortedIntervals,
  221 + anchor: archivedAnchor
  222 + )
  223 + }
  224 +}
  225 +
  226 +private extension AnchoredHealthDataReader {
  227 + static let commonDataTypes: [NativeHealthDataType] = [
  228 + .hrv,
  229 + .heartRate,
  230 + .walkingHeartRate,
  231 + .restingHeartRate,
  232 + .sleepingHeartRate,
  233 + .oxygenSaturation,
  234 + .steps,
  235 + .sleepingWristTemperature,
  236 + .respiratoryRate,
  237 + .irregularHeartRhythm,
  238 + ]
  239 +
  240 + static let activityDataTypes: Set<NativeHealthDataType> = [
  241 + .activeEnergy,
  242 + .exercise,
  243 + .stand,
  244 + ]
  245 +
  246 + func firstUploadStartDate() -> Date {
  247 + let years = NativeHealthUploadConfiguration.firstUploadLookbackYears
  248 + let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())
  249 + ?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))
  250 + return Calendar.current.startOfDay(for: date)
  251 + }
  252 +
  253 + func queryStartDate(for samples: [HKSample]) -> Date {
  254 + let earliest = samples.map(\.startDate).min() ?? firstUploadStartDate()
  255 + return max(Calendar.current.startOfDay(for: earliest), firstUploadStartDate())
  256 + }
  257 +
  258 + func fetchData(
  259 + type: NativeHealthDataType,
  260 + startDate: Date,
  261 + endDate: Date
  262 + ) async throws -> [NativeHealthDataPoint] {
  263 + switch type {
  264 + case .hrv:
  265 + return try await service.fetchHrvData(startDate: startDate, endDate: endDate)
  266 + case .heartRate:
  267 + return try await service.fetchHeartRateData(startDate: startDate, endDate: endDate)
  268 + case .walkingHeartRate:
  269 + return try await service.fetchWalkingHeartRateData(startDate: startDate, endDate: endDate)
  270 + case .restingHeartRate:
  271 + return try await service.fetchRestingHeartRateData(startDate: startDate, endDate: endDate)
  272 + case .sleepingHeartRate:
  273 + return try await service.fetchSleepingHeartRateData(startDate: startDate, endDate: endDate)
  274 + case .oxygenSaturation:
  275 + return try await service.fetchOxygenSaturationData(startDate: startDate, endDate: endDate)
  276 + case .activeEnergy:
  277 + return try await service.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
  278 + case .exercise:
  279 + return try await service.fetchExerciseData(startDate: startDate, endDate: endDate)
  280 + case .stand:
  281 + return try await service.fetchStandData(startDate: startDate, endDate: endDate)
  282 + case .steps:
  283 + return try await service.fetchStepCountData(startDate: startDate, endDate: endDate)
  284 + case .sleepingWristTemperature:
  285 + return try await service.fetchSleepingWristTemperatureData(startDate: startDate, endDate: endDate)
  286 + case .respiratoryRate:
  287 + return try await service.fetchRespiratoryRateData(startDate: startDate, endDate: endDate)
  288 + case .irregularHeartRhythm:
  289 + return try await service.fetchIrregularHeartRhythmData(startDate: startDate, endDate: endDate)
  290 + case .unknown, .sleep:
  291 + return []
  292 + }
  293 + }
  294 +
  295 + func fetchActivityData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
  296 + let activeEnergy = try await service.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
  297 + let exercise = try await service.fetchExerciseData(startDate: startDate, endDate: endDate)
  298 + let stand = try await service.fetchStandData(startDate: startDate, endDate: endDate)
  299 + return activeEnergy + exercise + stand
  300 + }
  301 +
  302 + func activityAnchoredChanges(
  303 + userId: Int,
  304 + dataType: NativeHealthDataType,
  305 + storedAnchorData: Data?,
  306 + initialStartDate: Date
  307 + ) async throws -> HealthKitService.AnchoredChanges {
  308 + Self.log(
  309 + "reader.activity.anchor.before userId=\(userId) sourceDataType=\(dataType.rawValue) anchorKey=\(AnchoredHealthUploadAnchorStore.activityAnchorKey) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(initialStartDate.timeIntervalSince1970))"
  310 + )
  311 + let anchor = storedAnchorData.flatMap {
  312 + try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: $0)
  313 + }
  314 + let changes = try await service.fetchAnchoredChanges(
  315 + for: dataType,
  316 + sourceIdentifier: Self.sourceIdentifier(for: dataType),
  317 + anchor: anchor,
  318 + initialStartDate: initialStartDate
  319 + )
  320 + let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
  321 + Self.log(
  322 + "reader.activity.anchor.query userId=\(userId) sourceDataType=\(dataType.rawValue) source=\(changes.sourceIdentifier) deleted=\(changes.deletedObjectCount) samples=\(changes.samples.count) sampleRange=\(Self.describeSamples(changes.samples)) newAnchor=\(Self.describeAnchorData(archivedAnchor))"
  323 + )
  324 + return changes
  325 + }
  326 +
  327 + static func sourceIdentifier(for type: NativeHealthDataType) -> String {
  328 + switch type {
  329 + case .hrv: return HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue
  330 + case .heartRate, .sleepingHeartRate: return HKQuantityTypeIdentifier.heartRate.rawValue
  331 + case .walkingHeartRate: return HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue
  332 + case .restingHeartRate: return HKQuantityTypeIdentifier.restingHeartRate.rawValue
  333 + case .oxygenSaturation: return HKQuantityTypeIdentifier.oxygenSaturation.rawValue
  334 + case .activeEnergy: return HKQuantityTypeIdentifier.activeEnergyBurned.rawValue
  335 + case .exercise: return HKQuantityTypeIdentifier.appleExerciseTime.rawValue
  336 + case .stand: return HKQuantityTypeIdentifier.appleStandTime.rawValue
  337 + case .steps: return HKQuantityTypeIdentifier.stepCount.rawValue
  338 + case .sleepingWristTemperature: return HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue
  339 + case .respiratoryRate: return HKQuantityTypeIdentifier.respiratoryRate.rawValue
  340 + case .irregularHeartRhythm: return HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue
  341 + case .sleep: return HKCategoryTypeIdentifier.sleepAnalysis.rawValue
  342 + case .unknown: return "unknown"
  343 + }
  344 + }
  345 +
  346 + static func deduplicateCommon(_ data: [NativeHealthDataPoint]) -> [NativeHealthDataPoint] {
  347 + var seen = Set<String>()
  348 + return data.filter { point in
  349 + let key = "\(point.dataType.rawValue)-\(point.time)-\(point.value)"
  350 + return seen.insert(key).inserted
  351 + }
  352 + }
  353 +
  354 + static func deduplicateSleep(_ data: [NativeSleepInterval]) -> [NativeSleepInterval] {
  355 + var seen = Set<String>()
  356 + return data.filter { interval in
  357 + let key = "\(interval.dataType)-\(interval.fromTime)-\(interval.toTime)"
  358 + return seen.insert(key).inserted
  359 + }
  360 + }
  361 +
  362 + static func log(_ message: String) {
  363 + DebugLogger.debugLog("[ArchUploader] \(message)")
  364 + }
  365 +
  366 + static func logCommonPoints(
  367 + _ points: [NativeHealthDataPoint],
  368 + userId: Int,
  369 + prefix: String
  370 + ) {
  371 + points.forEach { point in
  372 + log(
  373 + "\(prefix) userId=\(userId) dataType=\(point.dataType.rawValue) time=\(debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
  374 + )
  375 + }
  376 + }
  377 +
  378 + static func logSleepIntervals(
  379 + _ intervals: [NativeSleepInterval],
  380 + userId: Int,
  381 + prefix: String
  382 + ) {
  383 + intervals.forEach { interval in
  384 + log(
  385 + "\(prefix) userId=\(userId) dataType=\(interval.dataType) from=\(debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
  386 + )
  387 + }
  388 + }
  389 +
  390 + static func logActivityTargets(
  391 + _ targets: [NativeActivityTarget],
  392 + userId: Int,
  393 + prefix: String
  394 + ) {
  395 + targets.forEach { target in
  396 + let timestamp = target.healthValueTimestamp ?? 0
  397 + log(
  398 + "\(prefix) userId=\(userId) time=\(debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
  399 + )
  400 + }
  401 + }
  402 +
  403 + static func describeSamples(_ samples: [HKSample]) -> String {
  404 + guard !samples.isEmpty else { return "empty" }
  405 + let minStart = samples.map(\.startDate).min() ?? .distantPast
  406 + let maxEnd = samples.map(\.endDate).max() ?? .distantPast
  407 + return "\(debugTimestamp(minStart.timeIntervalSince1970))...\(debugTimestamp(maxEnd.timeIntervalSince1970))"
  408 + }
  409 +
  410 + static func describeCommonRange(_ points: [NativeHealthDataPoint]) -> String {
  411 + guard let minTime = points.map(\.time).min(),
  412 + let maxTime = points.map(\.time).max() else {
  413 + return "empty"
  414 + }
  415 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  416 + }
  417 +
  418 + static func describeSleepRange(_ intervals: [NativeSleepInterval]) -> String {
  419 + guard let minTime = intervals.map(\.fromTime).min(),
  420 + let maxTime = intervals.map(\.toTime).max() else {
  421 + return "empty"
  422 + }
  423 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  424 + }
  425 +
  426 + static func describeActivityTargetRange(_ targets: [NativeActivityTarget]) -> String {
  427 + let times = targets.compactMap(\.healthValueTimestamp)
  428 + guard let minTime = times.min(), let maxTime = times.max() else {
  429 + return "empty"
  430 + }
  431 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  432 + }
  433 +
  434 + static func describeActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle) -> String {
  435 + [
  436 + "activeEnergy=\(describeAnchorData(anchors.activeEnergy))",
  437 + "exercise=\(describeAnchorData(anchors.exercise))",
  438 + "stand=\(describeAnchorData(anchors.stand))",
  439 + ].joined(separator: ",")
  440 + }
  441 +
  442 + static func describeAnchorData(_ data: Data?) -> String {
  443 + guard let data else { return "anchor=none" }
  444 + return "anchor=size:\(data.count),hash:\(data.stableDebugHash)"
  445 + }
  446 +
  447 + static func debugTimestamp(_ timeInterval: TimeInterval) -> String {
  448 + debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
  449 + }
  450 +
  451 + static let debugDateFormatter: DateFormatter = {
  452 + let formatter = DateFormatter()
  453 + formatter.locale = Locale(identifier: "en_US_POSIX")
  454 + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
  455 + return formatter
  456 + }()
  457 +}
  458 +
  459 +private extension Data {
  460 + var stableDebugHash: String {
  461 + let hash = reduce(UInt64(14_695_981_039_346_656_037)) { result, byte in
  462 + (result ^ UInt64(byte)).multipliedReportingOverflow(by: 1_099_511_628_211).partialValue
  463 + }
  464 + return String(hash, radix: 16)
  465 + }
  466 +}
  1 +import Foundation
  2 +import HealthKit
  3 +
  4 +struct AnchoredHealthUploadSummary {
  5 + let commonUploadSuccess: Bool
  6 + let sleepUploadSuccess: Bool
  7 + let errorMessage: String?
  8 + let commonCount: Int
  9 + let sleepCount: Int
  10 +}
  11 +
  12 +struct AnchoredHealthUploadAnchorStore {
  13 + static let sleepAnchorKey = "sleep_all"
  14 + static let activityAnchorKey = "activity_all"
  15 +
  16 + private let defaults: UserDefaults
  17 + private let keyPrefix = "health_upload_query_anchor_all_v1"
  18 +
  19 + init(defaults: UserDefaults? = AppGroupConstants.defaults) {
  20 + self.defaults = defaults ?? .standard
  21 + }
  22 +
  23 + func anchor(userId: Int, dataType: NativeHealthDataType) -> HKQueryAnchor? {
  24 + guard let data = data(userId: userId, dataType: dataType) else { return nil }
  25 + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
  26 + }
  27 +
  28 + func anchor(userId: Int, anchorKey: String) -> HKQueryAnchor? {
  29 + guard let data = data(userId: userId, anchorKey: anchorKey) else { return nil }
  30 + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
  31 + }
  32 +
  33 + func activityAnchors(userId: Int) -> AnchoredHealthActivityAnchorBundle? {
  34 + guard let data = data(userId: userId, anchorKey: Self.activityAnchorKey) else { return nil }
  35 + return try? PropertyListDecoder().decode(AnchoredHealthActivityAnchorBundle.self, from: data)
  36 + }
  37 +
  38 + func data(userId: Int, dataType: NativeHealthDataType) -> Data? {
  39 + guard userId > 0 else { return nil }
  40 + return defaults.data(forKey: key(userId: userId, dataType: dataType))
  41 + }
  42 +
  43 + func data(userId: Int, anchorKey: String) -> Data? {
  44 + guard userId > 0 else { return nil }
  45 + return defaults.data(forKey: key(userId: userId, anchorKey: anchorKey))
  46 + }
  47 +
  48 + func save(_ data: Data, userId: Int, dataType: NativeHealthDataType) {
  49 + guard userId > 0 else { return }
  50 + defaults.set(data, forKey: key(userId: userId, dataType: dataType))
  51 + }
  52 +
  53 + func save(_ data: Data, userId: Int, anchorKey: String) {
  54 + guard userId > 0 else { return }
  55 + defaults.set(data, forKey: key(userId: userId, anchorKey: anchorKey))
  56 + }
  57 +
  58 + func saveActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle, userId: Int) throws {
  59 + guard userId > 0 else { return }
  60 + let data = try PropertyListEncoder().encode(anchors)
  61 + save(data, userId: userId, anchorKey: Self.activityAnchorKey)
  62 + }
  63 +
  64 + private func key(userId: Int, dataType: NativeHealthDataType) -> String {
  65 + "\(keyPrefix).user_\(userId).type_\(dataType.rawValue)"
  66 + }
  67 +
  68 + private func key(userId: Int, anchorKey: String) -> String {
  69 + "\(keyPrefix).user_\(userId).anchor_\(anchorKey)"
  70 + }
  71 +}
  72 +
  73 +/// A fully separate upload path for testing the all-types anchored strategy.
  74 +actor AnchoredHealthDataUploader {
  75 + static let shared = AnchoredHealthDataUploader()
  76 +
  77 + private let session: URLSession
  78 + private let reader: AnchoredHealthDataReader
  79 + private let anchorStore: AnchoredHealthUploadAnchorStore
  80 + private let userIdProvider: () -> Int?
  81 + private let uploadBatchSize = 300
  82 + private(set) var isUploadingAll = false
  83 + private var uploadAllPending = false
  84 + private var pendingUploadAllTypes = false
  85 + private var pendingUploadDataTypes = Set<NativeHealthDataType>()
  86 + private var uploadAllWaiters: [CheckedContinuation<AnchoredHealthUploadSummary, Never>] = []
  87 +
  88 + init(
  89 + session: URLSession = .shared,
  90 + anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
  91 + userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
  92 + ) {
  93 + self.session = session
  94 + self.anchorStore = anchorStore
  95 + self.userIdProvider = userIdProvider
  96 + self.reader = AnchoredHealthDataReader(anchorStore: anchorStore, userIdProvider: userIdProvider)
  97 + }
  98 +
  99 + func uploadAll(
  100 + dataTypes: Set<NativeHealthDataType>? = nil
  101 + ) async -> AnchoredHealthUploadSummary {
  102 + await runUploadAll(
  103 + dataTypes: dataTypes,
  104 + queueAnotherRunIfUploading: false
  105 + )
  106 + }
  107 +
  108 + func uploadAllAfterObservedChange(sampleTypeIdentifiers: Set<String>) async {
  109 + let mappedDataTypes = Self.uploadDataTypes(for: sampleTypeIdentifiers)
  110 + let shouldFallbackForMissingAnchor: Bool
  111 + if let mappedDataTypes,
  112 + let userId = userIdProvider(), userId > 0 {
  113 + shouldFallbackForMissingAnchor = hasMissingAnchor(dataTypes: mappedDataTypes, userId: userId)
  114 + } else {
  115 + shouldFallbackForMissingAnchor = mappedDataTypes != nil
  116 + }
  117 + let dataTypes = shouldFallbackForMissingAnchor ? nil : mappedDataTypes
  118 + let sources = sampleTypeIdentifiers.isEmpty
  119 + ? "unknown"
  120 + : sampleTypeIdentifiers.sorted().joined(separator: ",")
  121 + if shouldFallbackForMissingAnchor {
  122 + Self.log(
  123 + "uploader.observer.fallback reason=missingAnchor userId=\(userIdProvider() ?? -1)"
  124 + )
  125 + }
  126 + Self.log(
  127 + "uploader.observer.received userId=\(userIdProvider() ?? -1) sources=\(sources) dataTypes=\(Self.describeRequestedDataTypes(dataTypes))"
  128 + )
  129 + _ = await runUploadAll(
  130 + dataTypes: dataTypes,
  131 + queueAnotherRunIfUploading: true
  132 + )
  133 + }
  134 +
  135 + func uploadAllCommon(
  136 + dataTypes: Set<NativeHealthDataType>? = nil
  137 + ) async throws -> Int {
  138 + let result = try await reader.readCommon(dataTypes: dataTypes)
  139 + Self.log(
  140 + "uploader.common.read userId=\(userIdProvider() ?? -1) count=\(result.data.count) range=\(Self.describeCommonRange(result.data)) anchors=\(Self.describeAnchors(result.anchors))"
  141 + )
  142 + Self.logCommonPoints(result.data, userId: userIdProvider() ?? -1, prefix: "uploader.common.read.item")
  143 + return try await uploadCommon(result.data, anchors: result.anchors)
  144 + }
  145 +
  146 + func uploadAllSeelp() async throws -> Int {
  147 + try await uploadAllSleep()
  148 + }
  149 +
  150 + func uploadAllSleep() async throws -> Int {
  151 + let result = try await reader.readAllSleep()
  152 + Self.log(
  153 + "uploader.sleep.read userId=\(userIdProvider() ?? -1) count=\(result.data.count) range=\(Self.describeSleepRange(result.data)) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(result.anchor))"
  154 + )
  155 + Self.logSleepIntervals(result.data, userId: userIdProvider() ?? -1, prefix: "uploader.sleep.read.item")
  156 + return try await uploadSleep(result.data, anchor: result.anchor)
  157 + }
  158 +
  159 + func uploadAllActivity() async throws -> Int {
  160 + guard let userId = userIdProvider(), userId > 0 else {
  161 + throw NativeHealthUploadError.missingUserId
  162 + }
  163 + let result = try await reader.readActivity()
  164 + Self.log(
  165 + "uploader.activity.read userId=\(userId) count=\(result.data.count) targetCount=\(result.targets.count) range=\(Self.describeCommonRange(result.data)) targetRange=\(Self.describeActivityTargetRange(result.targets)) anchors=\(Self.describeActivityAnchors(result.anchors))"
  166 + )
  167 + Self.logCommonPoints(result.data, userId: userId, prefix: "uploader.activity.read.item")
  168 + Self.logActivityTargets(result.targets, userId: userId, prefix: "uploader.activity.target.read.item")
  169 + let commonCount = try await uploadCommon(result.data, anchors: [:])
  170 + try await uploadActivityTargets(result.targets)
  171 + try anchorStore.saveActivityAnchors(result.anchors, userId: userId)
  172 + Self.log(
  173 + "uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.activityAnchorKey) reason=activityFinished \(Self.describeActivityAnchors(result.anchors)) uploadedRange=\(Self.describeCommonRange(result.data)) targetRange=\(Self.describeActivityTargetRange(result.targets))"
  174 + )
  175 + return commonCount
  176 + }
  177 +}
  178 +
  179 +private extension AnchoredHealthDataUploader {
  180 + static let activityDataTypes: Set<NativeHealthDataType> = [
  181 + .activeEnergy,
  182 + .exercise,
  183 + .stand,
  184 + ]
  185 +
  186 + static func uploadDataTypes(
  187 + for sampleTypeIdentifiers: Set<String>
  188 + ) -> Set<NativeHealthDataType>? {
  189 + guard !sampleTypeIdentifiers.isEmpty else { return nil }
  190 + var dataTypes = Set<NativeHealthDataType>()
  191 +
  192 + for identifier in sampleTypeIdentifiers {
  193 + switch identifier {
  194 + case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:
  195 + dataTypes.insert(.hrv)
  196 + case HKQuantityTypeIdentifier.heartRate.rawValue:
  197 + dataTypes.formUnion([.heartRate, .sleepingHeartRate])
  198 + case HKQuantityTypeIdentifier.stepCount.rawValue:
  199 + dataTypes.insert(.steps)
  200 + case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:
  201 + dataTypes.insert(.oxygenSaturation)
  202 + case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
  203 + dataTypes.insert(.activeEnergy)
  204 + case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
  205 + dataTypes.insert(.exercise)
  206 + case HKQuantityTypeIdentifier.appleStandTime.rawValue:
  207 + dataTypes.insert(.stand)
  208 + case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
  209 + dataTypes.insert(.walkingHeartRate)
  210 + case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
  211 + dataTypes.insert(.restingHeartRate)
  212 + case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
  213 + dataTypes.insert(.sleepingWristTemperature)
  214 + case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
  215 + dataTypes.insert(.respiratoryRate)
  216 + case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:
  217 + dataTypes.insert(.irregularHeartRhythm)
  218 + case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
  219 + dataTypes.insert(.sleep)
  220 + default:
  221 + return nil
  222 + }
  223 + }
  224 + return dataTypes
  225 + }
  226 +
  227 + static func describeRequestedDataTypes(
  228 + _ dataTypes: Set<NativeHealthDataType>?
  229 + ) -> String {
  230 + dataTypes.map { types in
  231 + types.map(\.rawValue).sorted().map(String.init).joined(separator: ",")
  232 + } ?? "all"
  233 + }
  234 +
  235 + func hasMissingAnchor(dataTypes: Set<NativeHealthDataType>, userId: Int) -> Bool {
  236 + dataTypes.contains { dataType in
  237 + if dataType == .sleep {
  238 + return anchorStore.anchor(
  239 + userId: userId,
  240 + anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey
  241 + ) == nil
  242 + }
  243 + if Self.activityDataTypes.contains(dataType) {
  244 + return anchorStore.activityAnchors(userId: userId) == nil
  245 + }
  246 + return anchorStore.anchor(userId: userId, dataType: dataType) == nil
  247 + }
  248 + }
  249 +
  250 + func runUploadAll(
  251 + dataTypes: Set<NativeHealthDataType>?,
  252 + queueAnotherRunIfUploading: Bool
  253 + ) async -> AnchoredHealthUploadSummary {
  254 + if isUploadingAll {
  255 + if queueAnotherRunIfUploading {
  256 + uploadAllPending = true
  257 + if let dataTypes {
  258 + if !pendingUploadAllTypes {
  259 + pendingUploadDataTypes.formUnion(dataTypes)
  260 + }
  261 + } else {
  262 + pendingUploadAllTypes = true
  263 + pendingUploadDataTypes.removeAll()
  264 + }
  265 + Self.log(
  266 + "uploader.all.pending userId=\(userIdProvider() ?? -1) dataTypes=\(Self.describeRequestedDataTypes(dataTypes))"
  267 + )
  268 + } else {
  269 + Self.log("uploader.all.skip reason=alreadyUploading userId=\(userIdProvider() ?? -1)")
  270 + }
  271 + return await withCheckedContinuation { continuation in
  272 + uploadAllWaiters.append(continuation)
  273 + }
  274 + }
  275 +
  276 + isUploadingAll = true
  277 + var summary: AnchoredHealthUploadSummary
  278 + var requestedDataTypes = dataTypes
  279 + while true {
  280 + uploadAllPending = false
  281 + pendingUploadAllTypes = false
  282 + pendingUploadDataTypes.removeAll()
  283 + Self.log(
  284 + "uploader.all.start userId=\(userIdProvider() ?? -1) dataTypes=\(Self.describeRequestedDataTypes(requestedDataTypes))"
  285 + )
  286 + summary = await performUploadAll(dataTypes: requestedDataTypes)
  287 + Self.log(
  288 + "uploader.all.end userId=\(userIdProvider() ?? -1) commonSuccess=\(summary.commonUploadSuccess) sleepSuccess=\(summary.sleepUploadSuccess) commonCount=\(summary.commonCount) sleepCount=\(summary.sleepCount) error=\(summary.errorMessage ?? "nil")"
  289 + )
  290 + guard uploadAllPending else { break }
  291 + requestedDataTypes = pendingUploadAllTypes ? nil : pendingUploadDataTypes
  292 + }
  293 + isUploadingAll = false
  294 +
  295 + let waiters = uploadAllWaiters
  296 + uploadAllWaiters.removeAll()
  297 + waiters.forEach { $0.resume(returning: summary) }
  298 + return summary
  299 + }
  300 +
  301 + func performUploadAll(
  302 + dataTypes: Set<NativeHealthDataType>?
  303 + ) async -> AnchoredHealthUploadSummary {
  304 + guard AppShared.shared.token?.isEmpty == false else {
  305 + let message = NativeHealthUploadError.missingAccessToken.localizedDescription
  306 + return AnchoredHealthUploadSummary(
  307 + commonUploadSuccess: false,
  308 + sleepUploadSuccess: false,
  309 + errorMessage: message,
  310 + commonCount: 0,
  311 + sleepCount: 0
  312 + )
  313 + }
  314 +
  315 + var commonCount = 0
  316 + var sleepCount = 0
  317 + var commonUploadSuccess = true
  318 + var sleepUploadSuccess = true
  319 + var errorMessages: [String] = []
  320 + let commonTypes = dataTypes.map { types in
  321 + Set(types.filter {
  322 + $0 != .sleep && $0 != .unknown && !Self.activityDataTypes.contains($0)
  323 + })
  324 + }
  325 + let includesCommon = commonTypes == nil || commonTypes?.isEmpty == false
  326 + let includesActivity = dataTypes == nil || dataTypes?.contains(where: { Self.activityDataTypes.contains($0) }) == true
  327 + let includesSleep = dataTypes == nil || dataTypes?.contains(.sleep) == true
  328 +
  329 + if includesCommon {
  330 + do {
  331 + commonCount = try await uploadAllCommon(dataTypes: commonTypes)
  332 + } catch {
  333 + commonUploadSuccess = false
  334 + errorMessages.append("common 上传失败:\(error.localizedDescription)")
  335 + }
  336 + }
  337 +
  338 + if includesActivity {
  339 + do {
  340 + commonCount += try await uploadAllActivity()
  341 + } catch {
  342 + commonUploadSuccess = false
  343 + errorMessages.append("activity 上传失败:\(error.localizedDescription)")
  344 + }
  345 + }
  346 +
  347 + if includesSleep {
  348 + do {
  349 + sleepCount = try await uploadAllSleep()
  350 + } catch {
  351 + sleepUploadSuccess = false
  352 + errorMessages.append("sleep 上传失败:\(error.localizedDescription)")
  353 + }
  354 + }
  355 +
  356 + return AnchoredHealthUploadSummary(
  357 + commonUploadSuccess: commonUploadSuccess,
  358 + sleepUploadSuccess: sleepUploadSuccess,
  359 + errorMessage: errorMessages.isEmpty ? nil : errorMessages.joined(separator: "\n"),
  360 + commonCount: commonCount,
  361 + sleepCount: sleepCount
  362 + )
  363 + }
  364 +
  365 + func uploadCommon(
  366 + _ data: [NativeHealthDataPoint],
  367 + anchors: [NativeHealthDataType: Data]
  368 + ) async throws -> Int {
  369 + guard let userId = userIdProvider(), userId > 0 else {
  370 + throw NativeHealthUploadError.missingUserId
  371 + }
  372 + guard !data.isEmpty else {
  373 + Self.log(
  374 + "uploader.common.empty userId=\(userId) anchors=\(Self.describeAnchors(anchors))"
  375 + )
  376 + anchors.forEach { anchorStore.save($0.value, userId: userId, dataType: $0.key) }
  377 + anchors.forEach {
  378 + Self.log(
  379 + "uploader.anchor.saved userId=\(userId) dataType=\($0.key.rawValue) reason=emptyCommon \(Self.describeAnchorData($0.value))"
  380 + )
  381 + }
  382 + return 0
  383 + }
  384 +
  385 + Self.log(
  386 + "uploader.common.start userId=\(userId) count=\(data.count) range=\(Self.describeCommonRange(data)) batchSize=\(uploadBatchSize) anchors=\(Self.describeAnchors(anchors))"
  387 + )
  388 + var uploadedCount = 0
  389 + var remainingCountByType = anchors.keys.reduce(into: [NativeHealthDataType: Int]()) { result, type in
  390 + result[type] = 0
  391 + }
  392 + data.forEach { point in
  393 + remainingCountByType[point.dataType, default: 0] += 1
  394 + }
  395 +
  396 + for (batchIndex, batch) in SharedHealthAnchoredUploadSupport.batches(data, size: uploadBatchSize).enumerated() {
  397 + let list = batch.map {
  398 + [
  399 + "data_type": $0.dataType.rawValue,
  400 + "time": $0.time,
  401 + "value": $0.value,
  402 + ] as [String: Any]
  403 + }
  404 + try await request(
  405 + path: "/client/doublefeel/health/v2/data_upload/common/",
  406 + method: "POST",
  407 + body: ["data_list": list]
  408 + )
  409 +
  410 + uploadedCount += batch.count
  411 + for point in batch {
  412 + remainingCountByType[point.dataType, default: 0] -= 1
  413 + }
  414 + Self.log(
  415 + "uploader.common.batch.success userId=\(userId) batch=\(batchIndex + 1) count=\(batch.count) range=\(Self.describeCommonRange(batch)) uploadedCount=\(uploadedCount)/\(data.count)"
  416 + )
  417 + saveFinishedAnchors(
  418 + anchors,
  419 + userId: userId,
  420 + finishedTypes: remainingCountByType.filter { $0.value <= 0 }.map(\.key)
  421 + )
  422 + batch.forEach { point in
  423 + DebugLogger.debugLog(
  424 + "[ArchUploader] uploader.common.batch.success.item userId=\(userId) dataType=\(point.dataType.rawValue) time=\(Self.debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
  425 + )
  426 + }
  427 + }
  428 +
  429 + return uploadedCount
  430 + }
  431 +
  432 + func uploadActivityTargets(_ targets: [NativeActivityTarget]) async throws {
  433 + guard let userId = userIdProvider(), userId > 0 else {
  434 + throw NativeHealthUploadError.missingUserId
  435 + }
  436 + guard !targets.isEmpty else {
  437 + Self.log("uploader.activityTarget.empty userId=\(userId)")
  438 + return
  439 + }
  440 +
  441 + for (index, target) in targets.enumerated() {
  442 + try await request(
  443 + path: "/client/doublefeel/health/v2/activity_target/",
  444 + method: "POST",
  445 + body: target.uploadBody
  446 + )
  447 + let timestamp = target.healthValueTimestamp ?? 0
  448 + Self.log(
  449 + "uploader.activityTarget.success userId=\(userId) index=\(index + 1)/\(targets.count) time=\(Self.debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
  450 + )
  451 + }
  452 + }
  453 +
  454 + func uploadSleep(
  455 + _ data: [NativeSleepInterval],
  456 + anchor: Data
  457 + ) async throws -> Int {
  458 + guard let userId = userIdProvider(), userId > 0 else {
  459 + throw NativeHealthUploadError.missingUserId
  460 + }
  461 + guard !data.isEmpty else {
  462 + Self.log(
  463 + "uploader.sleep.empty userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
  464 + )
  465 + anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
  466 + Self.log(
  467 + "uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=emptySleep \(Self.describeAnchorData(anchor))"
  468 + )
  469 + return 0
  470 + }
  471 +
  472 + Self.log(
  473 + "uploader.sleep.start userId=\(userId) count=\(data.count) range=\(Self.describeSleepRange(data)) batchSize=\(uploadBatchSize) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
  474 + )
  475 + var uploadedCount = 0
  476 + for (batchIndex, batch) in SharedHealthAnchoredUploadSupport.batches(data, size: uploadBatchSize).enumerated() {
  477 + let list = batch.map {
  478 + [
  479 + "data_type": $0.dataType,
  480 + "from_time": $0.fromTime,
  481 + "to_time": $0.toTime,
  482 + ] as [String: Any]
  483 + }
  484 + try await request(
  485 + path: "/client/doublefeel/health/v2/data_upload/sleep/",
  486 + method: "POST",
  487 + body: ["data_list": list]
  488 + )
  489 +
  490 + uploadedCount += batch.count
  491 + Self.log(
  492 + "uploader.sleep.batch.success userId=\(userId) batch=\(batchIndex + 1) count=\(batch.count) range=\(Self.describeSleepRange(batch)) uploadedCount=\(uploadedCount)/\(data.count)"
  493 + )
  494 + if uploadedCount == data.count {
  495 + anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
  496 + Self.log(
  497 + "uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=sleepFinished \(Self.describeAnchorData(anchor)) uploadedRange=\(Self.describeSleepRange(data))"
  498 + )
  499 + }
  500 + batch.forEach { interval in
  501 + DebugLogger.debugLog(
  502 + "[ArchUploader] uploader.sleep.batch.success.item userId=\(userId) dataType=\(interval.dataType) from=\(Self.debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(Self.debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
  503 + )
  504 + }
  505 + }
  506 +
  507 + return uploadedCount
  508 + }
  509 +
  510 + func saveFinishedAnchors(
  511 + _ anchors: [NativeHealthDataType: Data],
  512 + userId: Int,
  513 + finishedTypes: [NativeHealthDataType]
  514 + ) {
  515 + for type in finishedTypes {
  516 + guard let anchor = anchors[type] else { continue }
  517 + anchorStore.save(anchor, userId: userId, dataType: type)
  518 + Self.log(
  519 + "uploader.anchor.saved userId=\(userId) dataType=\(type.rawValue) reason=commonTypeFinished \(Self.describeAnchorData(anchor))"
  520 + )
  521 + }
  522 + }
  523 +
  524 + @discardableResult
  525 + func request(path: String, method: String, body: [String: Any]? = nil) async throws -> Data {
  526 + guard let baseURL = URL(string: AppShared.shared.baseUrl),
  527 + let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
  528 + throw NativeHealthUploadError.invalidServerURL
  529 + }
  530 + guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else {
  531 + throw NativeHealthUploadError.missingAccessToken
  532 + }
  533 +
  534 + var request = URLRequest(url: url)
  535 + request.httpMethod = method
  536 + request.timeoutInterval = 60
  537 + request.setValue("application/json", forHTTPHeaderField: "Accept")
  538 + request.setValue(accessToken, forHTTPHeaderField: "access_token")
  539 + request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent")
  540 +
  541 + if let body {
  542 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  543 + request.httpBody = try JSONSerialization.data(withJSONObject: body)
  544 + }
  545 +
  546 + let (data, response) = try await session.data(for: request)
  547 + guard let httpResponse = response as? HTTPURLResponse else {
  548 + throw NativeHealthUploadError.invalidResponse
  549 + }
  550 + guard (200..<300).contains(httpResponse.statusCode) else {
  551 + if httpResponse.statusCode == 401 {
  552 + await MainActor.run {
  553 + AppShared.shared.logout()
  554 + }
  555 + }
  556 + throw NativeHealthUploadError.requestFailed(
  557 + path: path,
  558 + statusCode: httpResponse.statusCode,
  559 + body: String(data: data, encoding: .utf8)
  560 + )
  561 + }
  562 + return data
  563 + }
  564 +
  565 + static func debugTimestamp(_ timeInterval: TimeInterval) -> String {
  566 + Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
  567 + }
  568 +
  569 + static func log(_ message: String) {
  570 + DebugLogger.debugLog("[ArchUploader] \(message)")
  571 + }
  572 +
  573 + static func logCommonPoints(
  574 + _ points: [NativeHealthDataPoint],
  575 + userId: Int,
  576 + prefix: String
  577 + ) {
  578 + points.forEach { point in
  579 + log(
  580 + "\(prefix) userId=\(userId) dataType=\(point.dataType.rawValue) time=\(debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
  581 + )
  582 + }
  583 + }
  584 +
  585 + static func logSleepIntervals(
  586 + _ intervals: [NativeSleepInterval],
  587 + userId: Int,
  588 + prefix: String
  589 + ) {
  590 + intervals.forEach { interval in
  591 + log(
  592 + "\(prefix) userId=\(userId) dataType=\(interval.dataType) from=\(debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
  593 + )
  594 + }
  595 + }
  596 +
  597 + static func logActivityTargets(
  598 + _ targets: [NativeActivityTarget],
  599 + userId: Int,
  600 + prefix: String
  601 + ) {
  602 + targets.forEach { target in
  603 + let timestamp = target.healthValueTimestamp ?? 0
  604 + log(
  605 + "\(prefix) userId=\(userId) time=\(debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
  606 + )
  607 + }
  608 + }
  609 +
  610 + static func describeCommonRange(_ points: [NativeHealthDataPoint]) -> String {
  611 + guard let minTime = points.map(\.time).min(),
  612 + let maxTime = points.map(\.time).max() else {
  613 + return "empty"
  614 + }
  615 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  616 + }
  617 +
  618 + static func describeSleepRange(_ intervals: [NativeSleepInterval]) -> String {
  619 + guard let minTime = intervals.map(\.fromTime).min(),
  620 + let maxTime = intervals.map(\.toTime).max() else {
  621 + return "empty"
  622 + }
  623 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  624 + }
  625 +
  626 + static func describeActivityTargetRange(_ targets: [NativeActivityTarget]) -> String {
  627 + let times = targets.compactMap(\.healthValueTimestamp)
  628 + guard let minTime = times.min(),
  629 + let maxTime = times.max() else {
  630 + return "empty"
  631 + }
  632 + return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
  633 + }
  634 +
  635 + static func describeAnchors(_ anchors: [NativeHealthDataType: Data]) -> String {
  636 + anchors
  637 + .map { "type:\($0.key.rawValue)=\(describeAnchorData($0.value))" }
  638 + .sorted()
  639 + .joined(separator: ",")
  640 + }
  641 +
  642 + static func describeAnchorData(_ data: Data) -> String {
  643 + "anchor=size:\(data.count),hash:\(data.stableDebugHash)"
  644 + }
  645 +
  646 + static func describeActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle) -> String {
  647 + [
  648 + "activeEnergy=\(describeAnchorData(anchors.activeEnergy))",
  649 + "exercise=\(describeAnchorData(anchors.exercise))",
  650 + "stand=\(describeAnchorData(anchors.stand))",
  651 + ].joined(separator: ",")
  652 + }
  653 +
  654 + static let debugDateFormatter: DateFormatter = {
  655 + let formatter = DateFormatter()
  656 + formatter.locale = Locale(identifier: "en_US_POSIX")
  657 + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
  658 + return formatter
  659 + }()
  660 +}
  661 +
  662 +
  663 +private extension Data {
  664 + var stableDebugHash: String {
  665 + let hash = reduce(UInt64(14_695_981_039_346_656_037)) { result, byte in
  666 + (result ^ UInt64(byte)).multipliedReportingOverflow(by: 1_099_511_628_211).partialValue
  667 + }
  668 + return String(hash, radix: 16)
  669 + }
  670 +}
  1 +import Foundation
  2 +import HealthKit
  3 +
  4 +struct SharedHealthAnchoredChanges {
  5 + let samples: [HKSample]
  6 + let newAnchor: HKQueryAnchor
  7 + let deletedObjectCount: Int
  8 +}
  9 +
  10 +enum SharedHealthAnchoredQueryError: LocalizedError {
  11 + case missingAnchor
  12 +
  13 + var errorDescription: String? { "HealthKit did not return a query anchor." }
  14 +}
  15 +
  16 +final class SharedHealthAnchoredQueryReader {
  17 + private let healthStore: HKHealthStore
  18 +
  19 + init(healthStore: HKHealthStore) {
  20 + self.healthStore = healthStore
  21 + }
  22 +
  23 + func fetchChanges(
  24 + sampleType: HKSampleType,
  25 + anchor: HKQueryAnchor?,
  26 + initialStartDate: Date?
  27 + ) async throws -> SharedHealthAnchoredChanges {
  28 + let predicate = initialStartDate.map {
  29 + HKQuery.predicateForSamples(withStart: $0, end: Date(), options: [])
  30 + }
  31 +
  32 + return try await withCheckedThrowingContinuation { continuation in
  33 + let query = HKAnchoredObjectQuery(
  34 + type: sampleType,
  35 + predicate: predicate,
  36 + anchor: anchor,
  37 + limit: HKObjectQueryNoLimit
  38 + ) { _, samples, deletedObjects, newAnchor, error in
  39 + if let error {
  40 + continuation.resume(throwing: error)
  41 + } else if let newAnchor {
  42 + continuation.resume(returning: SharedHealthAnchoredChanges(
  43 + samples: samples ?? [],
  44 + newAnchor: newAnchor,
  45 + deletedObjectCount: deletedObjects?.count ?? 0
  46 + ))
  47 + } else {
  48 + continuation.resume(throwing: SharedHealthAnchoredQueryError.missingAnchor)
  49 + }
  50 + }
  51 + self.healthStore.execute(query)
  52 + }
  53 + }
  54 +}
  55 +
  56 +enum SharedHealthAnchoredUploadSupport {
  57 + static func archive(_ anchor: HKQueryAnchor) throws -> Data {
  58 + try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
  59 + }
  60 +
  61 + static func batches<Element>(_ values: [Element], size: Int) -> [[Element]] {
  62 + guard size > 0 else { return [values] }
  63 + return stride(from: 0, to: values.count, by: size).map {
  64 + Array(values[$0..<Swift.min($0 + size, values.count)])
  65 + }
  66 + }
  67 +}
  68 +
  69 +@MainActor
  70 +final class SharedHealthAnchoredObserverController {
  71 + private let healthStore: HKHealthStore
  72 + private let observedTypes: Set<HKSampleType>
  73 + private let isLoggedIn: () -> Bool
  74 + private let isAuthorized: () async -> Bool
  75 + private let onChanges: (Set<String>) async -> Void
  76 + private let log: (String) -> Void
  77 + private var observerQuery: HKObserverQuery?
  78 + private var started = false
  79 +
  80 + init(
  81 + healthStore: HKHealthStore,
  82 + observedTypes: Set<HKSampleType>,
  83 + isLoggedIn: @escaping () -> Bool,
  84 + isAuthorized: @escaping () async -> Bool,
  85 + onChanges: @escaping (Set<String>) async -> Void,
  86 + log: @escaping (String) -> Void
  87 + ) {
  88 + self.healthStore = healthStore
  89 + self.observedTypes = observedTypes
  90 + self.isLoggedIn = isLoggedIn
  91 + self.isAuthorized = isAuthorized
  92 + self.onChanges = onChanges
  93 + self.log = log
  94 + }
  95 +
  96 + func startIfNeeded() {
  97 + guard HKHealthStore.isHealthDataAvailable(), isLoggedIn() else { return }
  98 + Task { [weak self] in
  99 + guard let self else { return }
  100 + guard await isAuthorized() else {
  101 + log("observer.waitingForAuthorization")
  102 + return
  103 + }
  104 + startAuthorizedIfNeeded()
  105 + }
  106 + }
  107 +
  108 + func restartAfterAuthorization() {
  109 + stop()
  110 + startIfNeeded()
  111 + }
  112 +
  113 + func stop() {
  114 + if let observerQuery { healthStore.stop(observerQuery) }
  115 + observerQuery = nil
  116 + started = false
  117 + log("observer.stopped")
  118 + }
  119 +
  120 + private func startAuthorizedIfNeeded() {
  121 + guard isLoggedIn() else { return }
  122 + observedTypes.forEach(enableBackgroundDelivery)
  123 + guard !started else { return }
  124 +
  125 + let descriptors = observedTypes.map { HKQueryDescriptor(sampleType: $0, predicate: nil) }
  126 + let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] query, sampleTypes, completion, error in
  127 + Task { @MainActor in
  128 + guard let self else {
  129 + completion()
  130 + return
  131 + }
  132 + if let error {
  133 + self.log("observer.error error=\(error.localizedDescription)")
  134 + self.resetAfterError(query)
  135 + completion()
  136 + return
  137 + }
  138 + let identifiers = Set((sampleTypes ?? []).map(\.identifier))
  139 + self.log("observer.changed sources=\(identifiers.sorted().joined(separator: ","))")
  140 + await self.onChanges(identifiers)
  141 + completion()
  142 + }
  143 + }
  144 + observerQuery = query
  145 + started = true
  146 + healthStore.execute(query)
  147 + log("observer.started count=\(descriptors.count)")
  148 + }
  149 +
  150 + private func resetAfterError(_ query: HKObserverQuery) {
  151 + guard observerQuery === query else { return }
  152 + healthStore.stop(query)
  153 + observerQuery = nil
  154 + started = false
  155 + log("observer.resetAfterError")
  156 + }
  157 +
  158 + private func enableBackgroundDelivery(_ sampleType: HKSampleType) {
  159 + healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { [log] success, error in
  160 + if let error {
  161 + log("backgroundDelivery.failed source=\(sampleType.identifier) error=\(error.localizedDescription)")
  162 + } else {
  163 + log("backgroundDelivery source=\(sampleType.identifier) success=\(success)")
  164 + }
  165 + }
  166 + }
  167 +}
1 import Foundation 1 import Foundation
2 import HealthKit 2 import HealthKit
3 3
4 -/// Focused HealthKit query helper. It mirrors the original SwiftUI project data  
5 -/// coverage while avoiding dependencies on the old network and user modules.  
6 -final class HealthDataReader { 4 +/// Low-level HealthKit query helper shared by the host API and anchored reader.
  5 +/// It contains no upload or anchor persistence behavior.
  6 +final class HealthKitQueryReader {
7 private let healthStore: HKHealthStore 7 private let healthStore: HKHealthStore
8 8
9 init(healthStore: HKHealthStore) { 9 init(healthStore: HKHealthStore) {
@@ -435,7 +435,7 @@ final class HealthDataReader { @@ -435,7 +435,7 @@ final class HealthDataReader {
435 points.append( 435 points.append(
436 NativeHealthDataPoint( 436 NativeHealthDataPoint(
437 dataType: dataType, 437 dataType: dataType,
438 - time: latestEnd.timeIntervalSince1970, 438 + time: Self.uploadTimeForDailyPoint(day: day, latestEnd: latestEnd).timeIntervalSince1970,
439 value: value 439 value: value
440 ) 440 )
441 ) 441 )
@@ -480,7 +480,7 @@ final class HealthDataReader { @@ -480,7 +480,7 @@ final class HealthDataReader {
480 } 480 }
481 return NativeHealthDataPoint( 481 return NativeHealthDataPoint(
482 dataType: .stand, 482 dataType: .stand,
483 - time: latestEnd.timeIntervalSince1970, 483 + time: Self.uploadTimeForDailyPoint(day: date, latestEnd: latestEnd).timeIntervalSince1970,
484 value: summary.appleStandHours.doubleValue(for: .count()) 484 value: summary.appleStandHours.doubleValue(for: .count())
485 ) 485 )
486 } 486 }
@@ -492,6 +492,16 @@ final class HealthDataReader { @@ -492,6 +492,16 @@ final class HealthDataReader {
492 } 492 }
493 } 493 }
494 494
  495 + private static func uploadTimeForDailyPoint(day: Date, latestEnd: Date) -> Date {
  496 + let calendar = Calendar.current
  497 + let start = calendar.startOfDay(for: day)
  498 + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: start),
  499 + latestEnd >= nextDay else {
  500 + return latestEnd
  501 + }
  502 + return nextDay.addingTimeInterval(-1)
  503 + }
  504 +
495 private func fetchSleepIntervals(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] { 505 private func fetchSleepIntervals(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
496 guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else { 506 guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
497 throw NativeHealthKitError.invalidType("sleepAnalysis") 507 throw NativeHealthKitError.invalidType("sleepAnalysis")
@@ -636,6 +646,7 @@ final class HealthDataReader { @@ -636,6 +646,7 @@ final class HealthDataReader {
636 } 646 }
637 } 647 }
638 healthStore.execute(query) 648 healthStore.execute(query)
  649 +
639 } 650 }
640 } 651 }
641 } 652 }
@@ -6,16 +6,25 @@ import HealthKit @@ -6,16 +6,25 @@ import HealthKit
6 /// Responsibilities: 6 /// Responsibilities:
7 /// - request/read HealthKit permissions 7 /// - request/read HealthKit permissions
8 /// - read the health data types used by the original SwiftUI app 8 /// - read the health data types used by the original SwiftUI app
9 -/// - keep Watch complication values fresh in the shared App Group  
10 /// - register background observers so HealthKit changes refresh local state 9 /// - register background observers so HealthKit changes refresh local state
11 final class HealthKitService { 10 final class HealthKitService {
12 static let shared = HealthKitService() 11 static let shared = HealthKitService()
13 12
14 private let healthStore = HKHealthStore() 13 private let healthStore = HKHealthStore()
15 - private let syncStore = HealthSyncStateStore()  
16 - private lazy var reader = HealthDataReader(healthStore: healthStore)  
17 - private var observersStarted = false  
18 - private var observerQueries: [HKObserverQuery] = [] 14 + private lazy var reader = HealthKitQueryReader(healthStore: healthStore)
  15 + private lazy var anchoredReader = SharedHealthAnchoredQueryReader(healthStore: healthStore)
  16 + @MainActor private lazy var observerController = SharedHealthAnchoredObserverController(
  17 + healthStore: healthStore,
  18 + observedTypes: NativeHealthTypeCatalog.observedTypes,
  19 + isLoggedIn: { AppShared.shared.isLogin },
  20 + isAuthorized: { [weak self] in
  21 + await self?.authorizationRequestStatus() == .unnecessary
  22 + },
  23 + onChanges: { [weak self] identifiers in
  24 + await self?.handleObservedChanges(sampleTypeIdentifiers: identifiers)
  25 + },
  26 + log: { DebugLogger.debugLog("[ArchUploader] \($0)") }
  27 + )
19 28
20 private init() {} 29 private init() {}
21 30
@@ -58,8 +67,8 @@ final class HealthKitService { @@ -58,8 +67,8 @@ final class HealthKitService {
58 67
59 let endDate = Date() 68 let endDate = Date()
60 let startDate = requestedStartDate 69 let startDate = requestedStartDate
61 - ?? Calendar.current.date(byAdding: .year, value: -2, to: endDate)  
62 - ?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate) 70 + ?? Calendar.current.date(byAdding: .year, value: -1, to: endDate)
  71 + ?? Date(timeInterval: -1 * 365 * 24 * 60 * 60, since: endDate)
63 72
64 let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType } 73 let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
65 if await hasAnyReadableSample( 74 if await hasAnyReadableSample(
@@ -116,53 +125,21 @@ final class HealthKitService { @@ -116,53 +125,21 @@ final class HealthKitService {
116 } 125 }
117 126
118 func startBackgroundObserversIfNeeded() { 127 func startBackgroundObserversIfNeeded() {
119 - guard isHealthDataAvailable else { return }  
120 - // Enabling background delivery is safe to repeat and must be retried after  
121 - // authorization or a transient system failure.  
122 - NativeHealthTypeCatalog.observedTypes.forEach(enableBackgroundDelivery)  
123 - guard !observersStarted else { return }  
124 - observersStarted = true  
125 -  
126 - for sampleType in NativeHealthTypeCatalog.observedTypes {  
127 - let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in  
128 - guard error == nil else {  
129 - completion()  
130 - return  
131 - }  
132 - Task {  
133 - await self?.handleObservedChange(sampleType)  
134 - completion()  
135 - }  
136 - }  
137 - observerQueries.append(query)  
138 - healthStore.execute(query) 128 + Task { @MainActor [weak self] in
  129 + self?.observerController.startIfNeeded()
139 } 130 }
140 } 131 }
141 132
142 - func performLocalSync() async -> NativeHealthSyncSummary {  
143 - guard isHealthDataAvailable else {  
144 - return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: Date(), endedAt: Date())  
145 - }  
146 -  
147 - let startDate = earliestStartDate()  
148 - let endDate = Date()  
149 -  
150 - do {  
151 - let summary = try await reader.collectRecentData(startDate: startDate, endDate: endDate)  
152 - NativeHealthDataType.allCases  
153 - .filter { $0 != .unknown }  
154 - .forEach { syncStore.save(date: endDate, for: $0) }  
155 - await refreshSharedWatchValues()  
156 - startBackgroundObserversIfNeeded()  
157 - return summary  
158 - } catch {  
159 - await refreshSharedWatchValues()  
160 - return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: startDate, endedAt: endDate) 133 + func restartBackgroundObserversAfterAuthorization() {
  134 + Task { @MainActor [weak self] in
  135 + self?.observerController.restartAfterAuthorization()
161 } 136 }
162 } 137 }
163 138
164 - func refreshSharedWatchValues() async {  
165 - _ = WatchConnectivityService.shared.sendCommandMessage(AppGroupMessageKey.statusPulseRefresh) 139 + func stopBackgroundObservers() {
  140 + Task { @MainActor [weak self] in
  141 + self?.observerController.stop()
  142 + }
166 } 143 }
167 144
168 func fetchHrvData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] { 145 func fetchHrvData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
@@ -229,75 +206,77 @@ final class HealthKitService { @@ -229,75 +206,77 @@ final class HealthKitService {
229 try await reader.fetchActivityTargetDataList(startDate: startDate, endDate: endDate) 206 try await reader.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
230 } 207 }
231 208
232 - private func earliestStartDate() -> Date {  
233 - NativeHealthDataType.allCases  
234 - .filter { $0 != .unknown }  
235 - .map { syncStore.startDate(for: $0) }  
236 - .min() ?? Calendar.current.startOfDay(for: Date()) 209 + struct AnchoredChanges {
  210 + let samples: [HKSample]
  211 + let deletedObjectCount: Int
  212 + let newAnchor: HKQueryAnchor
  213 + let sourceIdentifier: String
237 } 214 }
238 215
239 - private func enableBackgroundDelivery(for sampleType: HKSampleType) {  
240 - let frequency: HKUpdateFrequency = sampleType.identifier == HKQuantityTypeIdentifier.stepCount.rawValue  
241 - ? .hourly  
242 - : .immediate  
243 -  
244 - healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in  
245 - if let error {  
246 - print("HealthKit background delivery failed: \(sampleType.identifier), \(error.localizedDescription)")  
247 - } else {  
248 - print("HealthKit background delivery \(success ? "enabled" : "not enabled"): \(sampleType.identifier)")  
249 - } 216 + func fetchAnchoredChanges(
  217 + for dataType: NativeHealthDataType,
  218 + sourceIdentifier overrideIdentifier: String? = nil,
  219 + anchor: HKQueryAnchor?,
  220 + initialStartDate: Date
  221 + ) async throws -> AnchoredChanges {
  222 + guard let sampleType = anchoredSampleType(for: dataType, overrideIdentifier: overrideIdentifier) else {
  223 + throw NativeHealthKitError.invalidType("anchor source for \(dataType.rawValue)")
250 } 224 }
  225 + let changes = try await anchoredReader.fetchChanges(
  226 + sampleType: sampleType,
  227 + anchor: anchor,
  228 + initialStartDate: initialStartDate
  229 + )
  230 + return AnchoredChanges(
  231 + samples: changes.samples,
  232 + deletedObjectCount: changes.deletedObjectCount,
  233 + newAnchor: changes.newAnchor,
  234 + sourceIdentifier: sampleType.identifier
  235 + )
251 } 236 }
252 237
253 - private func handleObservedChange(_ sampleType: HKSampleType) async {  
254 - switch sampleType.identifier {  
255 - case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue,  
256 - HKQuantityTypeIdentifier.stepCount.rawValue:  
257 - await refreshSharedWatchValues()  
258 - default:  
259 - break 238 + private func anchoredSampleType(
  239 + for dataType: NativeHealthDataType,
  240 + overrideIdentifier: String?
  241 + ) -> HKSampleType? {
  242 + if let overrideIdentifier {
  243 + if overrideIdentifier == HKCategoryTypeIdentifier.sleepAnalysis.rawValue {
  244 + return NativeHealthTypeCatalog.category(.sleepAnalysis)
  245 + }
  246 + if overrideIdentifier == HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue {
  247 + return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
  248 + }
  249 + return NativeHealthTypeCatalog.quantity(HKQuantityTypeIdentifier(rawValue: overrideIdentifier))
260 } 250 }
261 251
262 - let uploadTypes: [NativeHealthDataType]  
263 - let includeActivityTarget: Bool  
264 - switch sampleType.identifier {  
265 - case HKQuantityTypeIdentifier.heartRate.rawValue:  
266 - uploadTypes = [.heartRate, .sleepingHeartRate]  
267 - includeActivityTarget = false  
268 - case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:  
269 - uploadTypes = [.sleep, .sleepingHeartRate]  
270 - includeActivityTarget = false  
271 - default:  
272 - uploadTypes = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier).map { [$0] } ?? []  
273 - includeActivityTarget = [  
274 - HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,  
275 - HKQuantityTypeIdentifier.appleExerciseTime.rawValue,  
276 - HKQuantityTypeIdentifier.appleStandTime.rawValue,  
277 - ].contains(sampleType.identifier) 252 + switch dataType {
  253 + case .hrv: return NativeHealthTypeCatalog.quantity(.heartRateVariabilitySDNN)
  254 + case .heartRate, .sleepingHeartRate: return NativeHealthTypeCatalog.quantity(.heartRate)
  255 + case .walkingHeartRate: return NativeHealthTypeCatalog.quantity(.walkingHeartRateAverage)
  256 + case .restingHeartRate: return NativeHealthTypeCatalog.quantity(.restingHeartRate)
  257 + case .oxygenSaturation: return NativeHealthTypeCatalog.quantity(.oxygenSaturation)
  258 + case .activeEnergy: return NativeHealthTypeCatalog.quantity(.activeEnergyBurned)
  259 + case .exercise: return NativeHealthTypeCatalog.quantity(.appleExerciseTime)
  260 + case .stand: return NativeHealthTypeCatalog.quantity(.appleStandTime)
  261 + case .steps: return NativeHealthTypeCatalog.quantity(.stepCount)
  262 + case .sleepingWristTemperature: return NativeHealthTypeCatalog.quantity(.appleSleepingWristTemperature)
  263 + case .respiratoryRate: return NativeHealthTypeCatalog.quantity(.respiratoryRate)
  264 + case .irregularHeartRhythm: return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
  265 + case .sleep: return NativeHealthTypeCatalog.category(.sleepAnalysis)
  266 + case .unknown: return nil
278 } 267 }
  268 + }
279 269
280 - guard !uploadTypes.isEmpty || includeActivityTarget else { return }  
281 - let result = await NativeHealthDataUploader.shared.uploadObservedChange(  
282 - types: uploadTypes,  
283 - includeActivityTarget: includeActivityTarget,  
284 - observedSampleTypeIdentifier: sampleType.identifier,  
285 - service: self 270 + private func handleObservedChanges(sampleTypeIdentifiers: Set<String>) async {
  271 + guard AppShared.shared.isLogin else { return }
  272 + await AnchoredHealthDataUploader.shared.uploadAllAfterObservedChange(
  273 + sampleTypeIdentifiers: sampleTypeIdentifiers
286 ) 274 )
287 - if result.success {  
288 - result.latestTimestamps.forEach { type, timestamp in  
289 - syncStore.save(date: Date(timeIntervalSince1970: timestamp), for: type)  
290 - }  
291 - }  
292 } 275 }
293 276
294 - func uploadActivityTargetAfterForeground() async { 277 + func uploadHealthDataAfterForeground() async {
295 guard AppShared.shared.token?.isEmpty == false else { return } 278 guard AppShared.shared.token?.isEmpty == false else { return }
296 - _ = await NativeHealthDataUploader.shared.uploadObservedChange(  
297 - types: [],  
298 - includeActivityTarget: true,  
299 - service: self  
300 - ) 279 + _ = await AnchoredHealthDataUploader.shared.uploadAll()
301 } 280 }
302 } 281 }
303 282
@@ -317,38 +296,3 @@ private final class HealthReadableDataProbeState: @unchecked Sendable { @@ -317,38 +296,3 @@ private final class HealthReadableDataProbeState: @unchecked Sendable {
317 return dataFound 296 return dataFound
318 } 297 }
319 } 298 }
320 -  
321 -private extension NativeHealthDataType {  
322 - init?(sampleTypeIdentifier: String) {  
323 - switch sampleTypeIdentifier {  
324 - case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:  
325 - self = .hrv  
326 - case HKQuantityTypeIdentifier.heartRate.rawValue:  
327 - self = .heartRate  
328 - case HKQuantityTypeIdentifier.stepCount.rawValue:  
329 - self = .steps  
330 - case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:  
331 - self = .oxygenSaturation  
332 - case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:  
333 - self = .activeEnergy  
334 - case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:  
335 - self = .exercise  
336 - case HKQuantityTypeIdentifier.appleStandTime.rawValue:  
337 - self = .stand  
338 - case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:  
339 - self = .walkingHeartRate  
340 - case HKQuantityTypeIdentifier.restingHeartRate.rawValue:  
341 - self = .restingHeartRate  
342 - case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:  
343 - self = .sleepingWristTemperature  
344 - case HKQuantityTypeIdentifier.respiratoryRate.rawValue:  
345 - self = .respiratoryRate  
346 - case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:  
347 - self = .sleep  
348 - case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:  
349 - self = .irregularHeartRhythm  
350 - default:  
351 - return nil  
352 - }  
353 - }  
354 -}  
@@ -18,6 +18,39 @@ enum NativeHealthKitError: LocalizedError { @@ -18,6 +18,39 @@ enum NativeHealthKitError: LocalizedError {
18 } 18 }
19 } 19 }
20 20
  21 +enum NativeHealthUploadConfiguration {
  22 + /// Process-lifetime throttle. Each health data type can start at most one
  23 + /// upload request during this interval.
  24 + static let minimumTriggerInterval: TimeInterval = 60
  25 + static let firstUploadLookbackYears = 1
  26 + static let sleepCorrectionLookbackHours: TimeInterval = 36
  27 +}
  28 +enum NativeHealthUploadError: LocalizedError {
  29 + case invalidServerURL
  30 + case missingAccessToken
  31 + case invalidResponse
  32 + case requestFailed(path: String, statusCode: Int, body: String?)
  33 + case missingUserId
  34 + case invalidQueryAnchor
  35 +
  36 + var errorDescription: String? {
  37 + switch self {
  38 + case .invalidServerURL:
  39 + return "健康数据上传地址无效"
  40 + case .missingAccessToken:
  41 + return "缺少登录态,无法上传健康数据"
  42 + case .invalidResponse:
  43 + return "健康数据上传接口响应无效"
  44 + case .requestFailed(let path, let statusCode, let body):
  45 + return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")"
  46 + case .missingUserId:
  47 + return "缺少用户 ID,无法读取本地 HealthKit Anchor"
  48 + case .invalidQueryAnchor:
  49 + return "HealthKit Anchor 编解码失败"
  50 + }
  51 + }
  52 +}
  53 +
21 /// Raw values match the original SwiftUI project server contract. 54 /// Raw values match the original SwiftUI project server contract.
22 enum NativeHealthDataType: Int, Codable, CaseIterable, Hashable { 55 enum NativeHealthDataType: Int, Codable, CaseIterable, Hashable {
23 case unknown = 0 56 case unknown = 0
1 -import Foundation  
2 -import HealthKit  
3 -  
4 -enum NativeHealthUploadConfiguration {  
5 - /// Process-lifetime throttle. Each health data type can start at most one  
6 - /// upload request during this interval.  
7 - static let minimumTriggerInterval: TimeInterval = 60  
8 - static let firstUploadLookbackYears = 1  
9 -}  
10 -  
11 -struct NativeHealthUploadSummary {  
12 - let commonUploadSuccess: Bool  
13 - let sleepUploadSuccess: Bool  
14 - let errorMessage: String?  
15 - let commonCount: Int  
16 - let sleepCount: Int  
17 -}  
18 -  
19 -struct NativeHealthDebugUploadedDataPoint {  
20 - let dataType: NativeHealthDataType  
21 - let dataTypeRawValue: Int  
22 - let dataTypeName: String  
23 - let value: Double  
24 - let timestamp: TimeInterval  
25 -}  
26 -  
27 -enum NativeHealthUploadError: LocalizedError {  
28 - case invalidServerURL  
29 - case missingAccessToken  
30 - case invalidResponse  
31 - case requestFailed(path: String, statusCode: Int, body: String?)  
32 - case missingUserId  
33 - case invalidQueryAnchor  
34 -  
35 - var errorDescription: String? {  
36 - switch self {  
37 - case .invalidServerURL:  
38 - return "健康数据上传地址无效"  
39 - case .missingAccessToken:  
40 - return "缺少登录态,无法上传健康数据"  
41 - case .invalidResponse:  
42 - return "健康数据上传接口响应无效"  
43 - case .requestFailed(let path, let statusCode, let body):  
44 - return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")"  
45 - case .missingUserId:  
46 - return "缺少用户 ID,无法读取本地 HealthKit Anchor"  
47 - case .invalidQueryAnchor:  
48 - return "HealthKit Anchor 编解码失败"  
49 - }  
50 - }  
51 -}  
52 -  
53 -/// Uploads Apple Health data to DoubleFeel.  
54 -///  
55 -/// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only  
56 -/// owns upload requests. Upload boundaries always come from the server's  
57 -/// `/data_upload/common/` response.  
58 -actor NativeHealthDataUploader {  
59 - static let shared = NativeHealthDataUploader()  
60 - private nonisolated static let debugUploadedDataStore = NativeHealthDebugUploadedDataStore()  
61 -  
62 - struct ObservedUploadResult {  
63 - let success: Bool  
64 - let latestTimestamps: [NativeHealthDataType: TimeInterval]  
65 - }  
66 -  
67 - private let session: URLSession  
68 - private let defaultUploadTimeWeek = 1  
69 - private let uploadBatchSize = 500  
70 - private let syncStore = HealthSyncStateStore()  
71 - private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]  
72 -  
73 - init(session: URLSession = .shared) {  
74 - self.session = session  
75 - }  
76 -  
77 - func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {  
78 - guard AppShared.shared.token?.isEmpty == false else {  
79 - let message = NativeHealthUploadError.missingAccessToken.localizedDescription  
80 - return NativeHealthUploadSummary(  
81 - commonUploadSuccess: false,  
82 - sleepUploadSuccess: false,  
83 - errorMessage: message,  
84 - commonCount: 0,  
85 - sleepCount: 0  
86 - )  
87 - }  
88 - var commonCount = 0  
89 - var sleepCount = 0  
90 - var commonUploadSuccess = true  
91 - var sleepUploadSuccess = true  
92 - var errorMessages: [String] = []  
93 -  
94 - do {  
95 - let uploadTimeList = try await resolvedUploadTimeList()  
96 -  
97 - for type in Self.commonUploadTypes {  
98 - let result = await upload(  
99 - type: type,  
100 - uploadTimeList: uploadTimeList,  
101 - trigger: .full,  
102 - service: service  
103 - )  
104 - commitUploadTimestamps(result.latestUploadedTimestamps)  
105 - commonCount += result.uploadedCount  
106 - if !result.success {  
107 - commonUploadSuccess = false  
108 - if let errorMessage = result.errorMessage {  
109 - errorMessages.append(errorMessage)  
110 - }  
111 - }  
112 -  
113 - }  
114 -  
115 - let sleepResult = await upload(  
116 - type: .sleep,  
117 - uploadTimeList: uploadTimeList,  
118 - trigger: .full,  
119 - service: service  
120 - )  
121 - commitUploadTimestamps(sleepResult.latestUploadedTimestamps)  
122 - sleepCount = sleepResult.uploadedCount  
123 - sleepUploadSuccess = sleepResult.success  
124 - if let errorMessage = sleepResult.errorMessage {  
125 - errorMessages.append(errorMessage)  
126 - }  
127 -  
128 - let activityTargetUploaded = await uploadActivityTargetIfNeeded(  
129 - uploadTimeList: uploadTimeList,  
130 - service: service  
131 - )  
132 - commitUploadTimestamps(activityTargetUploaded.latestUploadedTimestamps)  
133 - if !activityTargetUploaded.success {  
134 - commonUploadSuccess = false  
135 - if let errorMessage = activityTargetUploaded.errorMessage {  
136 - errorMessages.append(errorMessage)  
137 - }  
138 - }  
139 - } catch {  
140 - commonUploadSuccess = false  
141 - sleepUploadSuccess = false  
142 - errorMessages.append(error.localizedDescription)  
143 - DebugLogger.debugLog("[iOS][upload][failed] type=uploadAll error=\(error.localizedDescription)")  
144 - }  
145 -  
146 - return NativeHealthUploadSummary(  
147 - commonUploadSuccess: commonUploadSuccess,  
148 - sleepUploadSuccess: sleepUploadSuccess,  
149 - errorMessage: errorMessages.isEmpty ? nil : errorMessages.joined(separator: "\n"),  
150 - commonCount: commonCount,  
151 - sleepCount: sleepCount  
152 - )  
153 - }  
154 -  
155 - func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {  
156 - guard AppShared.shared.token?.isEmpty == false else {  
157 - return false  
158 - }  
159 - do {  
160 - let uploadTimeList = try await resolvedUploadTimeList()  
161 - let result = await upload(  
162 - type: type,  
163 - uploadTimeList: uploadTimeList,  
164 - trigger: .manual,  
165 - service: service  
166 - )  
167 - commitUploadTimestamps(result.latestUploadedTimestamps)  
168 - return result.success  
169 - } catch {  
170 - DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")  
171 - return false  
172 - }  
173 - }  
174 -  
175 - func uploadObservedChange(  
176 - types: [NativeHealthDataType],  
177 - includeActivityTarget: Bool,  
178 - observedSampleTypeIdentifier: String? = nil,  
179 - service: HealthKitService = .shared  
180 - ) async -> ObservedUploadResult {  
181 - guard AppShared.shared.token?.isEmpty == false else {  
182 - return ObservedUploadResult(success: false, latestTimestamps: [:])  
183 - }  
184 -  
185 - do {  
186 - let uploadTimeList = try await resolvedUploadTimeList()  
187 - var success = true  
188 - var uploadedNewData = false  
189 - var latestTimestamps: [NativeHealthDataType: TimeInterval] = [:]  
190 - for type in types {  
191 - let result = await upload(  
192 - type: type,  
193 - uploadTimeList: uploadTimeList,  
194 - trigger: .observer,  
195 - service: service  
196 - )  
197 - commitUploadTimestamps(result.latestUploadedTimestamps)  
198 - success = success && result.success  
199 - uploadedNewData = uploadedNewData || result.uploadedCount > 0  
200 - if !result.latestUploadedTimestamps.isEmpty {  
201 - result.latestUploadedTimestamps.forEach { type, timestamp in  
202 - latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)  
203 - }  
204 - } else if let latestUploadedTimestamp = result.latestUploadedTimestamp {  
205 - latestTimestamps[type] = latestUploadedTimestamp  
206 - }  
207 - }  
208 - if includeActivityTarget && (uploadedNewData || types.isEmpty) {  
209 - let result = await uploadActivityTargetIfNeeded(  
210 - uploadTimeList: uploadTimeList,  
211 - service: service  
212 - )  
213 - commitUploadTimestamps(result.latestUploadedTimestamps)  
214 - success = success && result.success  
215 - result.latestUploadedTimestamps.forEach { type, timestamp in  
216 - latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)  
217 - }  
218 - }  
219 - return ObservedUploadResult(success: success, latestTimestamps: latestTimestamps)  
220 - } catch {  
221 - DebugLogger.debugLog("[iOS][upload][failed] type=observer error=\(error.localizedDescription)")  
222 - return ObservedUploadResult(success: false, latestTimestamps: [:])  
223 - }  
224 - }  
225 -  
226 - nonisolated func getDebugCurrentUploadedData() -> [NativeHealthDebugUploadedDataPoint] {  
227 - Self.debugUploadedDataStore.snapshot()  
228 - }  
229 -}  
230 -  
231 -private extension NativeHealthDataUploader {  
232 - struct UploadTaskResult {  
233 - let success: Bool  
234 - let uploadedCount: Int  
235 - let errorMessage: String?  
236 - var latestUploadedTimestamp: TimeInterval?  
237 - var latestUploadedTimestamps: [NativeHealthDataType: TimeInterval] = [:]  
238 - var wasThrottled = false  
239 - }  
240 -  
241 - enum UploadTrigger: String {  
242 - case full  
243 - case observer  
244 - case manual  
245 - }  
246 -  
247 - static let commonUploadTypes: [NativeHealthDataType] = [  
248 - .hrv,  
249 - .heartRate,  
250 - .walkingHeartRate,  
251 - .restingHeartRate,  
252 - .sleepingHeartRate,  
253 - .oxygenSaturation,  
254 - .activeEnergy,  
255 - .exercise,  
256 - .stand,  
257 - .steps,  
258 - .sleepingWristTemperature,  
259 - .respiratoryRate,  
260 - .irregularHeartRhythm,  
261 - ]  
262 - static let activityTargetAnchorType: NativeHealthDataType = .activeEnergy  
263 - static let activityTargetTimestampTypes: [NativeHealthDataType] = [  
264 - .activeEnergy,  
265 - .exercise,  
266 - .stand,  
267 - .steps,  
268 - ]  
269 - static let uploadAnchorTypes: [NativeHealthDataType] = [  
270 - .hrv,  
271 - .heartRate,  
272 - .walkingHeartRate,  
273 - .restingHeartRate,  
274 - .oxygenSaturation,  
275 - .activeEnergy,  
276 - .sleepingWristTemperature,  
277 - .respiratoryRate,  
278 - .irregularHeartRhythm,  
279 - .sleep,  
280 - ]  
281 -  
282 - static func uploadAnchorType(for type: NativeHealthDataType) -> NativeHealthDataType {  
283 - switch type {  
284 - case .sleepingHeartRate:  
285 - return .heartRate  
286 - case .exercise, .stand, .steps:  
287 - return .activeEnergy  
288 - default:  
289 - return type  
290 - }  
291 - }  
292 -  
293 - static func uploadTypes(forAnchorType anchorType: NativeHealthDataType) -> [NativeHealthDataType] {  
294 - NativeHealthDataType.allCases.filter {  
295 - $0 != .unknown && uploadAnchorType(for: $0) == anchorType  
296 - }  
297 - }  
298 -  
299 - func resolvedUploadTimeList() async throws -> NativeHealthUploadTimeList {  
300 - switch HealthUploadCursorConfiguration.mode {  
301 - case .serverTime:  
302 - return try await processLastUploadTime()  
303 - case .localAnchor:  
304 - let timestamp = firstUploadStartDate().timeIntervalSince1970  
305 - return NativeHealthUploadTimeList(latestDataTimeList: Self.uploadAnchorTypes.map { anchorType in  
306 - let date = syncStore.lastSyncDate(for: anchorType) ?? Date(timeIntervalSince1970: timestamp)  
307 - return .init(dataType: anchorType, latestDataTime: date.timeIntervalSince1970)  
308 - })  
309 - }  
310 - }  
311 -  
312 - func firstUploadStartDate() -> Date {  
313 - let years = NativeHealthUploadConfiguration.firstUploadLookbackYears  
314 - let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())  
315 - ?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))  
316 - return Calendar.current.startOfDay(for: date)  
317 - }  
318 -  
319 - func resolvedAnchorSourceIdentifier(  
320 - for type: NativeHealthDataType,  
321 - override: String?  
322 - ) -> String {  
323 - if let override { return override }  
324 - switch type {  
325 - case .hrv: return HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue  
326 - case .heartRate, .sleepingHeartRate: return HKQuantityTypeIdentifier.heartRate.rawValue  
327 - case .walkingHeartRate: return HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue  
328 - case .restingHeartRate: return HKQuantityTypeIdentifier.restingHeartRate.rawValue  
329 - case .oxygenSaturation: return HKQuantityTypeIdentifier.oxygenSaturation.rawValue  
330 - case .activeEnergy: return HKQuantityTypeIdentifier.activeEnergyBurned.rawValue  
331 - case .exercise: return HKQuantityTypeIdentifier.appleExerciseTime.rawValue  
332 - case .stand: return HKQuantityTypeIdentifier.appleStandTime.rawValue  
333 - case .steps: return HKQuantityTypeIdentifier.stepCount.rawValue  
334 - case .sleepingWristTemperature: return HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue  
335 - case .respiratoryRate: return HKQuantityTypeIdentifier.respiratoryRate.rawValue  
336 - case .irregularHeartRhythm: return HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue  
337 - case .sleep: return HKCategoryTypeIdentifier.sleepAnalysis.rawValue  
338 - case .unknown: return "unknown"  
339 - }  
340 - }  
341 -  
342 - func upload(  
343 - type: NativeHealthDataType,  
344 - uploadTimeList: NativeHealthUploadTimeList,  
345 - trigger: UploadTrigger,  
346 - service: HealthKitService  
347 - ) async -> UploadTaskResult {  
348 - guard type != .unknown,  
349 - let startUploadDate = uploadTimeList.latestDataTime(for: type) else {  
350 - DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=missing upload start date")  
351 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)  
352 - }  
353 -  
354 - let endUploadDate = Date()  
355 - guard endUploadDate >= startUploadDate else {  
356 - DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=start date is later than end date")  
357 - return UploadTaskResult(  
358 - success: false,  
359 - uploadedCount: 0,  
360 - errorMessage: "健康数据上传开始时间晚于结束时间:\(type)"  
361 - )  
362 - }  
363 -  
364 - do {  
365 - switch type {  
366 - case .sleep:  
367 - let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)  
368 - .filter { $0.toTime > startUploadDate.timeIntervalSince1970 }  
369 - .sorted { $0.toTime < $1.toTime }  
370 - guard !data.isEmpty else {  
371 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)  
372 - }  
373 - guard allowUploadTrigger(for: type) else {  
374 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)  
375 - }  
376 - try await uploadSleep(data)  
377 - let latestUploadedTimestamp = data.map(\.toTime).max()  
378 - notifyFlutterUpload(  
379 - type: type,  
380 - timestamp: latestUploadedTimestamp!  
381 - )  
382 - return UploadTaskResult(  
383 - success: true,  
384 - uploadedCount: data.count,  
385 - errorMessage: nil,  
386 - latestUploadedTimestamp: latestUploadedTimestamp,  
387 - latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]  
388 - )  
389 - default:  
390 - let data = try await fetchCommonData(  
391 - type: type,  
392 - startDate: startUploadDate,  
393 - endDate: endUploadDate,  
394 - service: service  
395 - )  
396 - .filter { $0.time > startUploadDate.timeIntervalSince1970 }  
397 - .sorted { $0.time < $1.time }  
398 - guard !data.isEmpty else {  
399 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)  
400 - }  
401 - guard allowUploadTrigger(for: type) else {  
402 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)  
403 - }  
404 - try await uploadCommon(data)  
405 - let latestUploadedTimestamp = data.map(\.time).max()  
406 - notifyFlutterUpload(  
407 - type: type,  
408 - timestamp: latestUploadedTimestamp!  
409 - )  
410 - return UploadTaskResult(  
411 - success: true,  
412 - uploadedCount: data.count,  
413 - errorMessage: nil,  
414 - latestUploadedTimestamp: latestUploadedTimestamp,  
415 - latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]  
416 - )  
417 - }  
418 - } catch {  
419 - DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")  
420 - return UploadTaskResult(  
421 - success: false,  
422 - uploadedCount: 0,  
423 - errorMessage: "健康数据上传失败:\(type), \(error.localizedDescription)"  
424 - )  
425 - }  
426 - }  
427 -  
428 - func commitUploadTimestamps(_ timestamps: [NativeHealthDataType: TimeInterval]) {  
429 - let grouped = timestamps.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, item in  
430 - let anchorType = Self.uploadAnchorType(for: item.key)  
431 - result[anchorType] = max(result[anchorType] ?? 0, item.value)  
432 - }  
433 - grouped.forEach { anchorType, timestamp in  
434 - let currentTimestamp = syncStore.lastSyncDate(for: anchorType)?.timeIntervalSince1970 ?? 0  
435 - syncStore.save(date: Date(timeIntervalSince1970: max(currentTimestamp, timestamp)), for: anchorType)  
436 - }  
437 - }  
438 -  
439 - func fetchCommonData(  
440 - type: NativeHealthDataType,  
441 - startDate: Date,  
442 - endDate: Date,  
443 - service: HealthKitService  
444 - ) async throws -> [NativeHealthDataPoint] {  
445 - switch type {  
446 - case .hrv:  
447 - return try await service.fetchHrvData(startDate: startDate, endDate: endDate)  
448 - case .heartRate:  
449 - return try await service.fetchHeartRateData(startDate: startDate, endDate: endDate)  
450 - case .walkingHeartRate:  
451 - return try await service.fetchWalkingHeartRateData(startDate: startDate, endDate: endDate)  
452 - case .restingHeartRate:  
453 - return try await service.fetchRestingHeartRateData(startDate: startDate, endDate: endDate)  
454 - case .sleepingHeartRate:  
455 - return try await service.fetchSleepingHeartRateData(startDate: startDate, endDate: endDate)  
456 - case .oxygenSaturation:  
457 - return try await service.fetchOxygenSaturationData(startDate: startDate, endDate: endDate)  
458 - case .activeEnergy:  
459 - return try await service.fetchActiveEnergyData(startDate: startDate, endDate: endDate)  
460 - case .exercise:  
461 - return try await service.fetchExerciseData(startDate: startDate, endDate: endDate)  
462 - case .stand:  
463 - return try await service.fetchStandData(startDate: startDate, endDate: endDate)  
464 - case .steps:  
465 - return try await service.fetchStepCountData(startDate: startDate, endDate: endDate)  
466 - case .sleepingWristTemperature:  
467 - return try await service.fetchSleepingWristTemperatureData(startDate: startDate, endDate: endDate)  
468 - case .respiratoryRate:  
469 - return try await service.fetchRespiratoryRateData(startDate: startDate, endDate: endDate)  
470 - case .irregularHeartRhythm:  
471 - return try await service.fetchIrregularHeartRhythmData(startDate: startDate, endDate: endDate)  
472 - case .unknown, .sleep:  
473 - return []  
474 - }  
475 - }  
476 -  
477 - func uploadActivityTargetIfNeeded(  
478 - uploadTimeList: NativeHealthUploadTimeList,  
479 - service: HealthKitService  
480 - ) async -> UploadTaskResult {  
481 - let calendar = Calendar.current  
482 - let recentStartDate = calendar.startOfDay(  
483 - for: calendar.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())  
484 - ?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)  
485 - )  
486 - let startDate = uploadTimeList.latestDataTime(for: Self.activityTargetAnchorType) ?? recentStartDate  
487 - let endDate = Date()  
488 -  
489 - do {  
490 - let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)  
491 - guard let target = targets.last,  
492 - targets.contains(where: { $0.move != nil || $0.stand != nil }) else {  
493 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)  
494 - }  
495 - try await uploadActivityTarget(target)  
496 - let uploadedTypes = try await uploadActivityTargetHealthValues(targets)  
497 - uploadedTypes.forEach {  
498 - notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp)  
499 - }  
500 - let latestTimestamp = uploadedTypes.map(\.timestamp).max()  
501 - let latestUploadedTimestamps = latestTimestamp.map { timestamp in  
502 - Self.activityTargetTimestampTypes.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, type in  
503 - result[type] = timestamp  
504 - }  
505 - } ?? [:]  
506 - return UploadTaskResult(  
507 - success: true,  
508 - uploadedCount: uploadedTypes.isEmpty ? 0 : 1,  
509 - errorMessage: nil,  
510 - latestUploadedTimestamp: latestTimestamp,  
511 - latestUploadedTimestamps: latestUploadedTimestamps  
512 - )  
513 - } catch {  
514 - DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")  
515 - return UploadTaskResult(  
516 - success: false,  
517 - uploadedCount: 0,  
518 - errorMessage: "活动目标上传失败:\(error.localizedDescription)"  
519 - )  
520 - }  
521 - }  
522 -  
523 - func processLastUploadTime() async throws -> NativeHealthUploadTimeList {  
524 - let serverTimeList = try await fetchLastUploadTime()  
525 - var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []  
526 -  
527 - let lookbackYears = NativeHealthUploadConfiguration.firstUploadLookbackYears  
528 - let firstUploadStartDate = Calendar.current.date(byAdding: .year, value: -lookbackYears, to: Date())  
529 - ?? Date(timeIntervalSinceNow: -TimeInterval(lookbackYears * 365 * 24 * 60 * 60))  
530 - let firstUploadStartTimestamp = Calendar.current.startOfDay(for: firstUploadStartDate).timeIntervalSince1970  
531 -  
532 - for anchorType in Self.uploadAnchorTypes {  
533 - let serverTime = Self.uploadTypes(forAnchorType: anchorType)  
534 - .compactMap { serverTimeList.rawLatestTimeInterval(for: $0) }  
535 - .max()  
536 - let finalTime = serverTime ?? firstUploadStartTimestamp  
537 -  
538 - resultTimeList.append(  
539 - NativeHealthUploadTimeList.HealthUploadTime(  
540 - dataType: anchorType,  
541 - latestDataTime: finalTime  
542 - )  
543 - )  
544 - }  
545 -  
546 - return NativeHealthUploadTimeList(latestDataTimeList: resultTimeList)  
547 - }  
548 -  
549 - func uploadCommon(_ data: [NativeHealthDataPoint]) async throws {  
550 - for batch in data.chunked(into: uploadBatchSize) {  
551 - batch.forEach { point in  
552 - DebugLogger.debugLog(  
553 - "[iOS][upload] dataType=\(point.dataType.rawValue)(\(point.dataType.debugName)), time=\(debugTimestamp(point.time)), unix=\(Int64(point.time)), value=\(point.value)"  
554 - )  
555 - }  
556 - let list = batch.map {  
557 - [  
558 - "data_type": $0.dataType.rawValue,  
559 - "time": $0.time,  
560 - "value": $0.value,  
561 - ] as [String: Any]  
562 - }  
563 - try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])  
564 - recordDebugUploadedCommonData(batch)  
565 - }  
566 - }  
567 -  
568 - func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {  
569 - if let lastDate = lastUploadTriggerDates[type],  
570 - now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {  
571 - return false  
572 - }  
573 - lastUploadTriggerDates[type] = now  
574 - return true  
575 - }  
576 -  
577 - func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {  
578 - let seconds = Int64(timestamp)  
579 - NotificationCenter.default.post(  
580 - name: .nativeHealthDataDidUpload,  
581 - object: nil,  
582 - userInfo: [  
583 - "dataType": type.rawValue,  
584 - "timestamp": seconds,  
585 - ]  
586 - )  
587 - }  
588 -  
589 - func uploadSleep(_ data: [NativeSleepInterval]) async throws {  
590 - for batch in data.chunked(into: uploadBatchSize) {  
591 - batch.forEach { interval in  
592 - DebugLogger.debugLog(  
593 - "[iOS][upload][sleep] dataType=\(interval.dataType), from=\(debugTimestamp(interval.fromTime)), fromUnix=\(Int64(interval.fromTime)), to=\(debugTimestamp(interval.toTime)), toUnix=\(Int64(interval.toTime))"  
594 - )  
595 - }  
596 - let list = batch.map {  
597 - [  
598 - "data_type": $0.dataType,  
599 - "from_time": $0.fromTime,  
600 - "to_time": $0.toTime,  
601 - ] as [String: Any]  
602 - }  
603 - try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])  
604 - recordDebugUploadedSleepData(batch)  
605 - }  
606 - }  
607 -  
608 - func uploadActivityTarget(_ target: NativeActivityTarget) async throws {  
609 - try await request(path: "/client/doublefeel/health/v2/activity_target/", method: "POST", body: target.uploadBody)  
610 - }  
611 -  
612 - func uploadActivityTargetHealthValues(  
613 - _ target: NativeActivityTarget  
614 - ) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {  
615 - try await uploadActivityTargetHealthValues([target])  
616 - }  
617 -  
618 - func uploadActivityTargetHealthValues(  
619 - _ targets: [NativeActivityTarget]  
620 - ) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {  
621 - var data: [NativeHealthDataPoint] = []  
622 - for target in targets {  
623 - guard let timestamp = target.healthValueTimestamp else { continue }  
624 - let values: [(NativeHealthDataType, Double?)] = [  
625 - (.activeEnergy, target.activeEnergyBurned),  
626 - (.exercise, target.appleExerciseTime),  
627 - (.stand, target.appleStandHours),  
628 - ]  
629 - for (type, value) in values {  
630 - guard let value else { continue }  
631 - data.append(  
632 - NativeHealthDataPoint(  
633 - dataType: type,  
634 - time: timestamp,  
635 - value: value  
636 - )  
637 - )  
638 - }  
639 - }  
640 - guard !data.isEmpty else { return [] }  
641 -  
642 - try await uploadCommon(data)  
643 - return data.map { ($0.dataType, $0.time) }  
644 - }  
645 -  
646 - func fetchLastUploadTime() async throws -> NativeHealthUploadTimeList {  
647 - let data = try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "GET")  
648 - return try NativeHealthUploadTimeList.decode(from: data)  
649 - }  
650 -  
651 - @discardableResult  
652 - func request(path: String, method: String, body: [String: Any]? = nil) async throws -> Data {  
653 - guard let baseURL = URL(string: AppShared.shared.baseUrl),  
654 - let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {  
655 - throw NativeHealthUploadError.invalidServerURL  
656 - }  
657 - guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else {  
658 - throw NativeHealthUploadError.missingAccessToken  
659 - }  
660 -  
661 - var request = URLRequest(url: url)  
662 - request.httpMethod = method  
663 - request.timeoutInterval = 60  
664 - request.setValue("application/json", forHTTPHeaderField: "Accept")  
665 - request.setValue(accessToken, forHTTPHeaderField: "access_token")  
666 - request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent")  
667 -  
668 - if let body {  
669 - request.setValue("application/json", forHTTPHeaderField: "Content-Type")  
670 - request.httpBody = try JSONSerialization.data(withJSONObject: body)  
671 - }  
672 -  
673 - let (data, response) = try await session.data(for: request)  
674 - guard let httpResponse = response as? HTTPURLResponse else {  
675 - throw NativeHealthUploadError.invalidResponse  
676 - }  
677 - guard (200..<300).contains(httpResponse.statusCode) else {  
678 - if httpResponse.statusCode == 401 {  
679 - await MainActor.run {  
680 - AppShared.shared.logout()  
681 - }  
682 - }  
683 - throw NativeHealthUploadError.requestFailed(  
684 - path: path,  
685 - statusCode: httpResponse.statusCode,  
686 - body: String(data: data, encoding: .utf8)  
687 - )  
688 - }  
689 - return data  
690 - }  
691 -  
692 - static let debugDateFormatter: DateFormatter = {  
693 - let formatter = DateFormatter()  
694 - formatter.locale = Locale(identifier: "en_US_POSIX")  
695 - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"  
696 - return formatter  
697 - }()  
698 -  
699 - func debugTimestamp(_ timeInterval: TimeInterval) -> String {  
700 - Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))  
701 - }  
702 -  
703 - func recordDebugUploadedCommonData(_ data: [NativeHealthDataPoint]) {  
704 - let points = data.map {  
705 - NativeHealthDebugUploadedDataPoint(  
706 - dataType: $0.dataType,  
707 - dataTypeRawValue: $0.dataType.rawValue,  
708 - dataTypeName: $0.dataType.debugName,  
709 - value: $0.value,  
710 - timestamp: $0.time  
711 - )  
712 - }  
713 - Self.debugUploadedDataStore.append(points)  
714 - }  
715 -  
716 - func recordDebugUploadedSleepData(_ data: [NativeSleepInterval]) {  
717 - let points = data.map {  
718 - NativeHealthDebugUploadedDataPoint(  
719 - dataType: .sleep,  
720 - dataTypeRawValue: NativeHealthDataType.sleep.rawValue,  
721 - dataTypeName: NativeHealthDataType.sleep.debugName,  
722 - value: Double($0.dataType),  
723 - timestamp: $0.toTime  
724 - )  
725 - }  
726 - Self.debugUploadedDataStore.append(points)  
727 - }  
728 -  
729 -}  
730 -  
731 -private final class NativeHealthDebugUploadedDataStore: @unchecked Sendable {  
732 - private let lock = NSLock()  
733 - private var points: [NativeHealthDebugUploadedDataPoint] = []  
734 -  
735 - func append(_ newPoints: [NativeHealthDebugUploadedDataPoint]) {  
736 - guard !newPoints.isEmpty else { return }  
737 - lock.lock()  
738 - points.append(contentsOf: newPoints)  
739 - lock.unlock()  
740 - }  
741 -  
742 - func snapshot() -> [NativeHealthDebugUploadedDataPoint] {  
743 - lock.lock()  
744 - let currentPoints = points  
745 - lock.unlock()  
746 - return currentPoints  
747 - }  
748 -}  
749 -  
750 -struct NativeHealthUploadTimeList: Codable {  
751 - struct HealthUploadTime: Codable {  
752 - let dataType: NativeHealthDataType?  
753 - let latestDataTime: TimeInterval?  
754 -  
755 - enum CodingKeys: String, CodingKey {  
756 - case dataType = "data_type"  
757 - case latestDataTime = "latest_data_time"  
758 - }  
759 - }  
760 -  
761 - var latestDataTimeList: [HealthUploadTime]  
762 -  
763 - enum CodingKeys: String, CodingKey {  
764 - case latestDataTimeList = "latest_data_time_list"  
765 - }  
766 -  
767 - func latestDataTime(for type: NativeHealthDataType) -> Date? {  
768 - latestTimeInterval(for: type).map(Date.init(timeIntervalSince1970:))  
769 - }  
770 -  
771 - func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {  
772 - rawLatestTimeInterval(for: NativeHealthDataUploader.uploadAnchorType(for: type))  
773 - }  
774 -  
775 - func rawLatestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {  
776 - latestDataTimeList.first { $0.dataType == type }?.latestDataTime  
777 - }  
778 -  
779 - static func decode(from data: Data) throws -> NativeHealthUploadTimeList {  
780 - let decoder = JSONDecoder()  
781 -  
782 - if let direct = try? decoder.decode(NativeHealthUploadTimeList.self, from: data) {  
783 - return direct  
784 - }  
785 -  
786 - let wrapped = try decoder.decode(HealthUploadTimeListResponse.self, from: data)  
787 - if let data = wrapped.data {  
788 - return data  
789 - }  
790 - throw NativeHealthUploadError.invalidResponse  
791 - }  
792 -}  
793 -  
794 -private extension Array {  
795 - func chunked(into size: Int) -> [[Element]] {  
796 - guard size > 0 else { return [self] }  
797 - return stride(from: 0, to: count, by: size).map {  
798 - Array(self[$0..<Swift.min($0 + size, count)])  
799 - }  
800 - }  
801 -}  
802 -  
803 -private struct HealthUploadTimeListResponse: Decodable {  
804 - let data: NativeHealthUploadTimeList?  
805 -}  
806 -  
807 -  
808 -private extension NativeHealthDataType {  
809 - var debugName: String {  
810 - switch self {  
811 - case .unknown:  
812 - return "unknown"  
813 - case .hrv:  
814 - return "hrv"  
815 - case .heartRate:  
816 - return "heartRate"  
817 - case .oxygenSaturation:  
818 - return "oxygenSaturation"  
819 - case .activeEnergy:  
820 - return "activeEnergy"  
821 - case .exercise:  
822 - return "exercise"  
823 - case .stand:  
824 - return "stand"  
825 - case .steps:  
826 - return "steps"  
827 - case .walkingHeartRate:  
828 - return "walkingHeartRate"  
829 - case .restingHeartRate:  
830 - return "restingHeartRate"  
831 - case .sleepingHeartRate:  
832 - return "sleepingHeartRate"  
833 - case .sleepingWristTemperature:  
834 - return "sleepingWristTemperature"  
835 - case .respiratoryRate:  
836 - return "respiratoryRate"  
837 - case .irregularHeartRhythm:  
838 - return "irregularHeartRhythm"  
839 - case .sleep:  
840 - return "sleep"  
841 - }  
842 - }  
843 -}  
@@ -8,16 +8,6 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -8,16 +8,6 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
8 self.service = service 8 self.service = service
9 } 9 }
10 10
11 - func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void) {  
12 - // Apple Health authorization is system-managed, not URL based.  
13 - completion(.success(""))  
14 - }  
15 -  
16 - func cancelHealthAppAuthorization() throws -> Bool {  
17 - // iOS does not let apps revoke HealthKit permission programmatically.  
18 - // Users must revoke access in Settings > Health > Data Access & Devices.  
19 - false  
20 - }  
21 11
22 func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) { 12 func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) {
23 Task { 13 Task {
@@ -32,6 +22,9 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -32,6 +22,9 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
32 if requestStatus == .shouldRequest { 22 if requestStatus == .shouldRequest {
33 authorization = HealthAuthorization(status: 0, hasData: false) 23 authorization = HealthAuthorization(status: 0, hasData: false)
34 } else { 24 } else {
  25 + // `.unnecessary` means another authorization request is not needed;
  26 + // HealthKit does not reveal whether read access was granted or denied.
  27 + // Probe readable samples to preserve the existing status contract.
35 let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) 28 let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date())
36 ?? Date(timeIntervalSinceNow: -30 * 24 * 60 * 60) 29 ?? Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)
37 let hasData = await service.hasAnyReadableData(startingAt: oneMonthAgo) 30 let hasData = await service.hasAnyReadableData(startingAt: oneMonthAgo)
@@ -39,8 +32,8 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -39,8 +32,8 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
39 } 32 }
40 } 33 }
41 34
42 - DebugLogger.log(  
43 - desc: "checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)" 35 + DebugLogger.debugLog(
  36 + "checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)"
44 ) 37 )
45 await MainActor.run { 38 await MainActor.run {
46 completion(.success(authorization)) 39 completion(.success(authorization))
@@ -49,21 +42,23 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -49,21 +42,23 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
49 } 42 }
50 43
51 func requestHealthClientAuthorization(completion: @escaping (Result<Bool, any Error>) -> Void) { 44 func requestHealthClientAuthorization(completion: @escaping (Result<Bool, any Error>) -> Void) {
52 - DebugLogger.log(desc:"requestHealthClientAuthorization") 45 + DebugLogger.debugLog("requestHealthClientAuthorization")
53 service.requestAuthorization { [service] success, error in 46 service.requestAuthorization { [service] success, error in
54 if let error { 47 if let error {
55 - DebugLogger.log(desc:"requestHealthClientAuthorization error: \(error)") 48 + DebugLogger.debugLog("requestHealthClientAuthorization error: \(error)")
56 completion(.failure(error)) 49 completion(.failure(error))
57 return 50 return
58 } 51 }
59 52
60 Task { 53 Task {
  54 + // HealthKit deliberately does not expose read authorization per type.
  55 + // A successful authorization request means the sheet completed; an
  56 + // empty store must not be treated as denied permission.
61 let granted = success 57 let granted = success
62 - DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)") 58 + DebugLogger.debugLog("requestHealthClientAuthorization granted: \(granted)")
63 if granted { 59 if granted {
64 - service.startBackgroundObserversIfNeeded()  
65 - await service.refreshSharedWatchValues()  
66 - _ = await NativeHealthDataUploader.shared.uploadAll(service: service) 60 + service.restartBackgroundObserversAfterAuthorization()
  61 + _ = await AnchoredHealthDataUploader.shared.uploadAll()
67 } 62 }
68 completion(.success(granted)) 63 completion(.success(granted))
69 } 64 }
@@ -72,7 +67,7 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -72,7 +67,7 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
72 67
73 func performHealthUpload(completion: @escaping (Result<HealthUploadResult, any Error>) -> Void) { 68 func performHealthUpload(completion: @escaping (Result<HealthUploadResult, any Error>) -> Void) {
74 Task{ 69 Task{
75 - let summary = await NativeHealthDataUploader.shared.uploadAll(service: self.service) 70 + let summary = await AnchoredHealthDataUploader.shared.uploadAll()
76 let result = HealthUploadResult( 71 let result = HealthUploadResult(
77 commonUploadSuccess: summary.commonUploadSuccess, 72 commonUploadSuccess: summary.commonUploadSuccess,
78 sleepUploadSuccess: summary.sleepUploadSuccess, 73 sleepUploadSuccess: summary.sleepUploadSuccess,
@@ -81,6 +76,21 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -81,6 +76,21 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
81 completion(.success(result)) 76 completion(.success(result))
82 } 77 }
83 } 78 }
  79 + //MARK: -DEBUG
  80 + func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void) {
  81 + // Apple Health authorization is system-managed, not URL based.
  82 + completion(.success(""))
  83 + }
  84 +
  85 + func cancelHealthAppAuthorization() throws -> Bool {
  86 + // iOS does not let apps revoke HealthKit permission programmatically.
  87 + // Users must revoke access in Settings > Health > Data Access & Devices.
  88 + false
  89 + }
  90 +
  91 + func getDebugCurrentUploadedData() throws -> [HealthUploadDataPoint] {
  92 + []
  93 + }
84 94
85 func fetchHrvData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void) { 95 func fetchHrvData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void) {
86 fetchCommon(startTime: startTime, endTime: endTime, service.fetchHrvData, completion: completion) 96 fetchCommon(startTime: startTime, endTime: endTime, service.fetchHrvData, completion: completion)
@@ -160,7 +170,16 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -160,7 +170,16 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
160 completion(.success(target.map { 170 completion(.success(target.map {
161 HealthActivityTargetData( 171 HealthActivityTargetData(
162 move: $0.move.map(Int64.init), 172 move: $0.move.map(Int64.init),
163 - stand: $0.stand.map(Int64.init) 173 + stand: $0.stand.map(Int64.init),
  174 +// activityMoveMode: $0.activityMoveMode.map(Int64.init),
  175 +// activeEnergyBurned: $0.activeEnergyBurned,
  176 +// activeEnergyBurnedGoal: $0.activeEnergyBurnedGoal,
  177 +// appleMoveTime: $0.appleMoveTime,
  178 +// appleMoveTimeGoal: $0.appleMoveTimeGoal,
  179 +// appleExerciseTime: $0.appleExerciseTime,
  180 +// exerciseTimeGoal: $0.exerciseTimeGoal,
  181 +// appleStandHours: $0.appleStandHours,
  182 +// standHoursGoal: $0.standHoursGoal
164 ) 183 )
165 })) 184 }))
166 } catch { 185 } catch {
@@ -161,7 +161,7 @@ final class PlatformHostApiImpl: PlatformHostApi { @@ -161,7 +161,7 @@ final class PlatformHostApiImpl: PlatformHostApi {
161 Task{ 161 Task{
162 await AppShared.shared.reportDeviceInfo() 162 await AppShared.shared.reportDeviceInfo()
163 if await HealthKitService.shared.hasAnyReadableData() { 163 if await HealthKitService.shared.hasAnyReadableData() {
164 - _ = await NativeHealthDataUploader.shared.uploadAll() 164 + _ = await AnchoredHealthDataUploader.shared.uploadAll()
165 } 165 }
166 } 166 }
167 } 167 }
@@ -22,7 +22,7 @@ struct RunnerApp: App { @@ -22,7 +22,7 @@ struct RunnerApp: App {
22 appDelegate.setup() 22 appDelegate.setup()
23 HealthKitService.shared.startBackgroundObserversIfNeeded() 23 HealthKitService.shared.startBackgroundObserversIfNeeded()
24 Task { 24 Task {
25 - await HealthKitService.shared.uploadActivityTargetAfterForeground() 25 + await HealthKitService.shared.uploadHealthDataAfterForeground()
26 } 26 }
27 } 27 }
28 } 28 }
@@ -103,9 +103,6 @@ class MyController extends GetxController { @@ -103,9 +103,6 @@ class MyController extends GetxController {
103 await loadWatchThemes(); 103 await loadWatchThemes();
104 } 104 }
105 105
106 - void testAppleHealthUpload() {  
107 - Get.toNamed(Routes.APPLE_HEALTH_UPLOAD_TEST);  
108 - }  
109 106
110 Future<void> toPremiumPage() async { 107 Future<void> toPremiumPage() async {
111 await Get.toNamed(Routes.PURCHASE, arguments: { 108 await Get.toNamed(Routes.PURCHASE, arguments: {
@@ -68,13 +68,6 @@ class MyTab extends GetView<MyController> { @@ -68,13 +68,6 @@ class MyTab extends GetView<MyController> {
68 if (environmentConfig.isDebug) ...[ 68 if (environmentConfig.isDebug) ...[
69 const SizedBox(height: 12), 69 const SizedBox(height: 12),
70 _SettingsRow( 70 _SettingsRow(
71 - title: 'Apple Health Upload 测试',  
72 - onTap: () {  
73 - controller.testAppleHealthUpload();  
74 - },  
75 - ),  
76 - const SizedBox(height: 12),  
77 - _SettingsRow(  
78 title: 'Developer options', 71 title: 'Developer options',
79 onTap: () { 72 onTap: () {
80 Get.toNamed(Routes.DEVELOPER_OPTIONS); 73 Get.toNamed(Routes.DEVELOPER_OPTIONS);
@@ -2,6 +2,7 @@ import 'dart:async'; @@ -2,6 +2,7 @@ import 'dart:async';
2 import 'dart:io'; 2 import 'dart:io';
3 3
4 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 4 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  5 +import 'package:doublefeel_flutter/core/constants/app_const.dart';
5 import 'package:doublefeel_flutter/core/network/api/theme_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
6 import 'package:doublefeel_flutter/core/result/app_result.dart'; 7 import 'package:doublefeel_flutter/core/result/app_result.dart';
7 import 'package:doublefeel_flutter/core/util/app_toast.dart'; 8 import 'package:doublefeel_flutter/core/util/app_toast.dart';
@@ -234,6 +235,13 @@ class CreateWatchThemeController extends GetxController { @@ -234,6 +235,13 @@ class CreateWatchThemeController extends GetxController {
234 agreedToSubmission.toggle(); 235 agreedToSubmission.toggle();
235 } 236 }
236 237
  238 + void openSubmissionAgreement() {
  239 + Get.toNamed(
  240 + AppRoutes.webview,
  241 + parameters: {'url': AppConst.userSubmissionAgreement},
  242 + );
  243 + }
  244 +
237 Future<void> saveCustomTheme() async { 245 Future<void> saveCustomTheme() async {
238 if (!canSaveCustomTheme) { 246 if (!canSaveCustomTheme) {
239 return; 247 return;
1 import 'dart:io'; 1 import 'dart:io';
2 2
  3 +import 'package:flutter/gestures.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
4 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
@@ -251,9 +252,24 @@ class _AgreementRow extends StatelessWidget { @@ -251,9 +252,24 @@ class _AgreementRow extends StatelessWidget {
251 : null, 252 : null,
252 ), 253 ),
253 SizedBox(width: 4), 254 SizedBox(width: 4),
254 - Text(  
255 - context.l10n.watchThemeSubmissionAgreement,  
256 - style: TextStyle( 255 + Text.rich(
  256 + TextSpan(
  257 + children: [
  258 + TextSpan(
  259 + text: context.l10n.watchThemeSubmissionAgreementPrefix,
  260 + ),
  261 + TextSpan(
  262 + text: context.l10n.watchThemeSubmissionAgreementLink,
  263 + style: TextStyle(
  264 + color: Color(0xFF0F0F11),
  265 + fontSize: 12,
  266 + ),
  267 + recognizer: TapGestureRecognizer()
  268 + ..onTap = controller.openSubmissionAgreement,
  269 + ),
  270 + ],
  271 + ),
  272 + style: const TextStyle(
257 color: WatchThemeColors.textSecondary, 273 color: WatchThemeColors.textSecondary,
258 fontSize: 12, 274 fontSize: 12,
259 ), 275 ),
@@ -23,4 +23,6 @@ abstract final class AppConst { @@ -23,4 +23,6 @@ abstract final class AppConst {
23 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html'; 23 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html';
24 static const String privacyPolicy = 24 static const String privacyPolicy =
25 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html'; 25 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html';
  26 + static const String userSubmissionAgreement =
  27 + 'https://cdn.doublefeel.cn/doublefeel/protocol/creator.html';
26 } 28 }
@@ -649,6 +649,8 @@ @@ -649,6 +649,8 @@
649 "watchThemeName": "Theme Name", 649 "watchThemeName": "Theme Name",
650 "watchThemeNameMaxLength": "Up to 10 characters", 650 "watchThemeNameMaxLength": "Up to 10 characters",
651 "watchThemeSubmissionAgreement": "I have read and agree to the User Submission Agreement", 651 "watchThemeSubmissionAgreement": "I have read and agree to the User Submission Agreement",
  652 + "watchThemeSubmissionAgreementPrefix": "I have read and agree to the User ",
  653 + "watchThemeSubmissionAgreementLink": "Submission Agreement",
652 "watchThemeSaving": "Saving", 654 "watchThemeSaving": "Saving",
653 "watchThemeSaveTheme": "Save Theme", 655 "watchThemeSaveTheme": "Save Theme",
654 "watchThemeExcellent": "Excellent", 656 "watchThemeExcellent": "Excellent",
@@ -695,4 +697,4 @@ @@ -695,4 +697,4 @@
695 "feedbackSubmitSuccessTitle": "Feedback submitted successfully", 697 "feedbackSubmitSuccessTitle": "Feedback submitted successfully",
696 "feedbackSubmitSuccessMessage": "Thank you for your feedback. If further communication is needed, we will contact you via the email address you left as soon as possible. Please keep an eye on your inbox.", 698 "feedbackSubmitSuccessMessage": "Thank you for your feedback. If further communication is needed, we will contact you via the email address you left as soon as possible. Please keep an eye on your inbox.",
697 "feedbackSubmitSuccessConfirm": "OK" 699 "feedbackSubmitSuccessConfirm": "OK"
698 -}  
  700 +}
@@ -1028,6 +1028,8 @@ @@ -1028,6 +1028,8 @@
1028 "watchThemeName": "主题名称", 1028 "watchThemeName": "主题名称",
1029 "watchThemeNameMaxLength": "最多10个字符", 1029 "watchThemeNameMaxLength": "最多10个字符",
1030 "watchThemeSubmissionAgreement": "我已阅读并同意用户投稿协议", 1030 "watchThemeSubmissionAgreement": "我已阅读并同意用户投稿协议",
  1031 + "watchThemeSubmissionAgreementPrefix": "我已阅读并同意用户",
  1032 + "watchThemeSubmissionAgreementLink": "投稿协议",
1031 "watchThemeSaving": "保存中", 1033 "watchThemeSaving": "保存中",
1032 "watchThemeSaveTheme": "保存主题", 1034 "watchThemeSaveTheme": "保存主题",
1033 "watchThemeExcellent": "状态优秀", 1035 "watchThemeExcellent": "状态优秀",
@@ -3807,6 +3807,18 @@ abstract class AppLocalizations { @@ -3807,6 +3807,18 @@ abstract class AppLocalizations {
3807 /// **'我已阅读并同意用户投稿协议'** 3807 /// **'我已阅读并同意用户投稿协议'**
3808 String get watchThemeSubmissionAgreement; 3808 String get watchThemeSubmissionAgreement;
3809 3809
  3810 + /// No description provided for @watchThemeSubmissionAgreementPrefix.
  3811 + ///
  3812 + /// In zh, this message translates to:
  3813 + /// **'我已阅读并同意用户'**
  3814 + String get watchThemeSubmissionAgreementPrefix;
  3815 +
  3816 + /// No description provided for @watchThemeSubmissionAgreementLink.
  3817 + ///
  3818 + /// In zh, this message translates to:
  3819 + /// **'投稿协议'**
  3820 + String get watchThemeSubmissionAgreementLink;
  3821 +
3810 /// No description provided for @watchThemeSaving. 3822 /// No description provided for @watchThemeSaving.
3811 /// 3823 ///
3812 /// In zh, this message translates to: 3824 /// In zh, this message translates to:
@@ -2135,6 +2135,13 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2135,6 +2135,13 @@ class AppLocalizationsEn extends AppLocalizations {
2135 'I have read and agree to the User Submission Agreement'; 2135 'I have read and agree to the User Submission Agreement';
2136 2136
2137 @override 2137 @override
  2138 + String get watchThemeSubmissionAgreementPrefix =>
  2139 + 'I have read and agree to the User ';
  2140 +
  2141 + @override
  2142 + String get watchThemeSubmissionAgreementLink => 'Submission Agreement';
  2143 +
  2144 + @override
2138 String get watchThemeSaving => 'Saving'; 2145 String get watchThemeSaving => 'Saving';
2139 2146
2140 @override 2147 @override
@@ -2031,6 +2031,12 @@ class AppLocalizationsZh extends AppLocalizations { @@ -2031,6 +2031,12 @@ class AppLocalizationsZh extends AppLocalizations {
2031 String get watchThemeSubmissionAgreement => '我已阅读并同意用户投稿协议'; 2031 String get watchThemeSubmissionAgreement => '我已阅读并同意用户投稿协议';
2032 2032
2033 @override 2033 @override
  2034 + String get watchThemeSubmissionAgreementPrefix => '我已阅读并同意用户';
  2035 +
  2036 + @override
  2037 + String get watchThemeSubmissionAgreementLink => '投稿协议';
  2038 +
  2039 + @override
2034 String get watchThemeSaving => '保存中'; 2040 String get watchThemeSaving => '保存中';
2035 2041
2036 @override 2042 @override