Showing
20 changed files
with
1535 additions
and
1023 deletions
| 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 |
-
Please register or login to post a comment