|
|
|
import Foundation
|
|
|
|
import HealthKit
|
|
|
|
|
|
|
|
enum NativeHealthUploadConfiguration {
|
|
|
|
/// Process-lifetime throttle. Each health data type can start at most one
|
|
|
|
/// upload request during this interval.
|
|
|
|
static let minimumTriggerInterval: TimeInterval = 60
|
|
|
|
static let firstUploadLookbackYears = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
struct NativeHealthUploadSummary {
|
|
...
|
...
|
@@ -14,11 +16,21 @@ struct NativeHealthUploadSummary { |
|
|
|
let sleepCount: Int
|
|
|
|
}
|
|
|
|
|
|
|
|
struct NativeHealthDebugUploadedDataPoint {
|
|
|
|
let dataType: NativeHealthDataType
|
|
|
|
let dataTypeRawValue: Int
|
|
|
|
let dataTypeName: String
|
|
|
|
let value: Double
|
|
|
|
let timestamp: TimeInterval
|
|
|
|
}
|
|
|
|
|
|
|
|
enum NativeHealthUploadError: LocalizedError {
|
|
|
|
case invalidServerURL
|
|
|
|
case missingAccessToken
|
|
|
|
case invalidResponse
|
|
|
|
case requestFailed(path: String, statusCode: Int, body: String?)
|
|
|
|
case missingUserId
|
|
|
|
case invalidQueryAnchor
|
|
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
|
switch self {
|
|
...
|
...
|
@@ -30,6 +42,10 @@ enum NativeHealthUploadError: LocalizedError { |
|
|
|
return "健康数据上传接口响应无效"
|
|
|
|
case .requestFailed(let path, let statusCode, let body):
|
|
|
|
return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")"
|
|
|
|
case .missingUserId:
|
|
|
|
return "缺少用户 ID,无法读取本地 HealthKit Anchor"
|
|
|
|
case .invalidQueryAnchor:
|
|
|
|
return "HealthKit Anchor 编解码失败"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
...
|
...
|
@@ -41,12 +57,17 @@ enum NativeHealthUploadError: LocalizedError { |
|
|
|
/// `/data_upload/common/` response.
|
|
|
|
actor NativeHealthDataUploader {
|
|
|
|
static let shared = NativeHealthDataUploader()
|
|
|
|
private nonisolated static let debugUploadedDataStore = NativeHealthDebugUploadedDataStore()
|
|
|
|
|
|
|
|
struct ObservedUploadResult {
|
|
|
|
let success: Bool
|
|
|
|
let latestTimestamps: [NativeHealthDataType: TimeInterval]
|
|
|
|
}
|
|
|
|
|
|
|
|
private let session: URLSession
|
|
|
|
private let defaultUploadTimeWeek = 1
|
|
|
|
private let firstUploadYear = 1
|
|
|
|
private let uploadBatchSize = 500
|
|
|
|
private let sleepUploadLookback: TimeInterval = 24 * 60 * 60
|
|
|
|
private let syncStore = HealthSyncStateStore()
|
|
|
|
private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
|
|
|
|
|
|
|
|
init(session: URLSession = .shared) {
|
|
...
|
...
|
@@ -56,7 +77,6 @@ actor NativeHealthDataUploader { |
|
|
|
func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
let message = NativeHealthUploadError.missingAccessToken.localizedDescription
|
|
|
|
DebugLogger.debugLog("uploadAll skipped, user is not logged in")
|
|
|
|
return NativeHealthUploadSummary(
|
|
|
|
commonUploadSuccess: false,
|
|
|
|
sleepUploadSuccess: false,
|
|
...
|
...
|
@@ -71,9 +91,8 @@ actor NativeHealthDataUploader { |
|
|
|
var sleepUploadSuccess = true
|
|
|
|
var errorMessages: [String] = []
|
|
|
|
|
|
|
|
DebugLogger.debugLog("uploadAll started ")
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await processLastUploadTime()
|
|
|
|
let uploadTimeList = try await resolvedUploadTimeList()
|
|
|
|
|
|
|
|
for type in Self.commonUploadTypes {
|
|
|
|
let result = await upload(
|
|
...
|
...
|
@@ -82,6 +101,7 @@ actor NativeHealthDataUploader { |
|
|
|
trigger: .full,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
commonCount += result.uploadedCount
|
|
|
|
if !result.success {
|
|
|
|
commonUploadSuccess = false
|
|
...
|
...
|
@@ -89,6 +109,7 @@ actor NativeHealthDataUploader { |
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let sleepResult = await upload(
|
|
...
|
...
|
@@ -97,6 +118,7 @@ actor NativeHealthDataUploader { |
|
|
|
trigger: .full,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(sleepResult.latestUploadedTimestamps)
|
|
|
|
sleepCount = sleepResult.uploadedCount
|
|
|
|
sleepUploadSuccess = sleepResult.success
|
|
|
|
if let errorMessage = sleepResult.errorMessage {
|
|
...
|
...
|
@@ -107,6 +129,7 @@ actor NativeHealthDataUploader { |
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(activityTargetUploaded.latestUploadedTimestamps)
|
|
|
|
if !activityTargetUploaded.success {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
if let errorMessage = activityTargetUploaded.errorMessage {
|
|
...
|
...
|
@@ -117,11 +140,9 @@ actor NativeHealthDataUploader { |
|
|
|
commonUploadSuccess = false
|
|
|
|
sleepUploadSuccess = false
|
|
|
|
errorMessages.append(error.localizedDescription)
|
|
|
|
DebugLogger.debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=uploadAll error=\(error.localizedDescription)")
|
|
|
|
}
|
|
|
|
|
|
|
|
DebugLogger.debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
|
|
|
|
|
|
|
|
return NativeHealthUploadSummary(
|
|
|
|
commonUploadSuccess: commonUploadSuccess,
|
|
|
|
sleepUploadSuccess: sleepUploadSuccess,
|
|
...
|
...
|
@@ -133,20 +154,20 @@ actor NativeHealthDataUploader { |
|
|
|
|
|
|
|
func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
DebugLogger.debugLog("upload(\(type.debugName)) skipped, user is not logged in")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await processLastUploadTime()
|
|
|
|
return await upload(
|
|
|
|
let uploadTimeList = try await resolvedUploadTimeList()
|
|
|
|
let result = await upload(
|
|
|
|
type: type,
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
trigger: .manual,
|
|
|
|
service: service
|
|
|
|
).success
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
return result.success
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
|
|
|
|
DebugLogger.debugLog("Health upload failed to process last upload time: \(error.localizedDescription)")
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
...
|
...
|
@@ -154,17 +175,18 @@ actor NativeHealthDataUploader { |
|
|
|
func uploadObservedChange(
|
|
|
|
types: [NativeHealthDataType],
|
|
|
|
includeActivityTarget: Bool,
|
|
|
|
observedSampleTypeIdentifier: String? = nil,
|
|
|
|
service: HealthKitService = .shared
|
|
|
|
) async -> Bool {
|
|
|
|
) async -> ObservedUploadResult {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
DebugLogger.debugLog("observed change skipped, user is not logged in")
|
|
|
|
return false
|
|
|
|
return ObservedUploadResult(success: false, latestTimestamps: [:])
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await processLastUploadTime()
|
|
|
|
let uploadTimeList = try await resolvedUploadTimeList()
|
|
|
|
var success = true
|
|
|
|
var uploadedNewData = false
|
|
|
|
var latestTimestamps: [NativeHealthDataType: TimeInterval] = [:]
|
|
|
|
for type in types {
|
|
|
|
let result = await upload(
|
|
|
|
type: type,
|
|
...
|
...
|
@@ -172,22 +194,38 @@ actor NativeHealthDataUploader { |
|
|
|
trigger: .observer,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
success = success && result.success
|
|
|
|
uploadedNewData = uploadedNewData || result.uploadedCount > 0
|
|
|
|
if !result.latestUploadedTimestamps.isEmpty {
|
|
|
|
result.latestUploadedTimestamps.forEach { type, timestamp in
|
|
|
|
latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
|
|
|
|
}
|
|
|
|
} else if let latestUploadedTimestamp = result.latestUploadedTimestamp {
|
|
|
|
latestTimestamps[type] = latestUploadedTimestamp
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if includeActivityTarget && uploadedNewData {
|
|
|
|
if includeActivityTarget && (uploadedNewData || types.isEmpty) {
|
|
|
|
let result = await uploadActivityTargetIfNeeded(
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
success = success && result.success
|
|
|
|
result.latestUploadedTimestamps.forEach { type, timestamp in
|
|
|
|
latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return success
|
|
|
|
return ObservedUploadResult(success: success, latestTimestamps: latestTimestamps)
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("observed change upload failed, error=\(error.localizedDescription)")
|
|
|
|
return false
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=observer error=\(error.localizedDescription)")
|
|
|
|
return ObservedUploadResult(success: false, latestTimestamps: [:])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
nonisolated func getDebugCurrentUploadedData() -> [NativeHealthDebugUploadedDataPoint] {
|
|
|
|
Self.debugUploadedDataStore.snapshot()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private extension NativeHealthDataUploader {
|
|
...
|
...
|
@@ -195,6 +233,9 @@ private extension NativeHealthDataUploader { |
|
|
|
let success: Bool
|
|
|
|
let uploadedCount: Int
|
|
|
|
let errorMessage: String?
|
|
|
|
var latestUploadedTimestamp: TimeInterval?
|
|
|
|
var latestUploadedTimestamps: [NativeHealthDataType: TimeInterval] = [:]
|
|
|
|
var wasThrottled = false
|
|
|
|
}
|
|
|
|
|
|
|
|
enum UploadTrigger: String {
|
|
...
|
...
|
@@ -218,6 +259,85 @@ private extension NativeHealthDataUploader { |
|
|
|
.respiratoryRate,
|
|
|
|
.irregularHeartRhythm,
|
|
|
|
]
|
|
|
|
static let activityTargetAnchorType: NativeHealthDataType = .activeEnergy
|
|
|
|
static let activityTargetTimestampTypes: [NativeHealthDataType] = [
|
|
|
|
.activeEnergy,
|
|
|
|
.exercise,
|
|
|
|
.stand,
|
|
|
|
.steps,
|
|
|
|
]
|
|
|
|
static let uploadAnchorTypes: [NativeHealthDataType] = [
|
|
|
|
.hrv,
|
|
|
|
.heartRate,
|
|
|
|
.walkingHeartRate,
|
|
|
|
.restingHeartRate,
|
|
|
|
.oxygenSaturation,
|
|
|
|
.activeEnergy,
|
|
|
|
.sleepingWristTemperature,
|
|
|
|
.respiratoryRate,
|
|
|
|
.irregularHeartRhythm,
|
|
|
|
.sleep,
|
|
|
|
]
|
|
|
|
|
|
|
|
static func uploadAnchorType(for type: NativeHealthDataType) -> NativeHealthDataType {
|
|
|
|
switch type {
|
|
|
|
case .sleepingHeartRate:
|
|
|
|
return .heartRate
|
|
|
|
case .exercise, .stand, .steps:
|
|
|
|
return .activeEnergy
|
|
|
|
default:
|
|
|
|
return type
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static func uploadTypes(forAnchorType anchorType: NativeHealthDataType) -> [NativeHealthDataType] {
|
|
|
|
NativeHealthDataType.allCases.filter {
|
|
|
|
$0 != .unknown && uploadAnchorType(for: $0) == anchorType
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func resolvedUploadTimeList() async throws -> NativeHealthUploadTimeList {
|
|
|
|
switch HealthUploadCursorConfiguration.mode {
|
|
|
|
case .serverTime:
|
|
|
|
return try await processLastUploadTime()
|
|
|
|
case .localAnchor:
|
|
|
|
let timestamp = firstUploadStartDate().timeIntervalSince1970
|
|
|
|
return NativeHealthUploadTimeList(latestDataTimeList: Self.uploadAnchorTypes.map { anchorType in
|
|
|
|
let date = syncStore.lastSyncDate(for: anchorType) ?? Date(timeIntervalSince1970: timestamp)
|
|
|
|
return .init(dataType: anchorType, latestDataTime: date.timeIntervalSince1970)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func firstUploadStartDate() -> Date {
|
|
|
|
let years = NativeHealthUploadConfiguration.firstUploadLookbackYears
|
|
|
|
let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))
|
|
|
|
return Calendar.current.startOfDay(for: date)
|
|
|
|
}
|
|
|
|
|
|
|
|
func resolvedAnchorSourceIdentifier(
|
|
|
|
for type: NativeHealthDataType,
|
|
|
|
override: String?
|
|
|
|
) -> String {
|
|
|
|
if let override { return override }
|
|
|
|
switch type {
|
|
|
|
case .hrv: return HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue
|
|
|
|
case .heartRate, .sleepingHeartRate: return HKQuantityTypeIdentifier.heartRate.rawValue
|
|
|
|
case .walkingHeartRate: return HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue
|
|
|
|
case .restingHeartRate: return HKQuantityTypeIdentifier.restingHeartRate.rawValue
|
|
|
|
case .oxygenSaturation: return HKQuantityTypeIdentifier.oxygenSaturation.rawValue
|
|
|
|
case .activeEnergy: return HKQuantityTypeIdentifier.activeEnergyBurned.rawValue
|
|
|
|
case .exercise: return HKQuantityTypeIdentifier.appleExerciseTime.rawValue
|
|
|
|
case .stand: return HKQuantityTypeIdentifier.appleStandTime.rawValue
|
|
|
|
case .steps: return HKQuantityTypeIdentifier.stepCount.rawValue
|
|
|
|
case .sleepingWristTemperature: return HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue
|
|
|
|
case .respiratoryRate: return HKQuantityTypeIdentifier.respiratoryRate.rawValue
|
|
|
|
case .irregularHeartRhythm: return HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue
|
|
|
|
case .sleep: return HKCategoryTypeIdentifier.sleepAnalysis.rawValue
|
|
|
|
case .unknown: return "unknown"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func upload(
|
|
|
|
type: NativeHealthDataType,
|
|
...
|
...
|
@@ -227,16 +347,13 @@ private extension NativeHealthDataUploader { |
|
|
|
) async -> UploadTaskResult {
|
|
|
|
guard type != .unknown,
|
|
|
|
let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] skipped, no upload start date")
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=missing upload start date")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
let endUploadDate = Date()
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[\(type.debugName)] upload started, trigger=\(trigger.rawValue), range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
|
|
|
|
)
|
|
|
|
guard endUploadDate >= startUploadDate else {
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] upload failed, start date is later than end date")
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=start date is later than end date")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
...
|
...
|
@@ -247,33 +364,29 @@ private extension NativeHealthDataUploader { |
|
|
|
do {
|
|
|
|
switch type {
|
|
|
|
case .sleep:
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] reading sleep data")
|
|
|
|
let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
|
|
|
|
.filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
|
|
|
|
.sorted { $0.toTime < $1.toTime }
|
|
|
|
debugLogTimeComparison(
|
|
|
|
type: type,
|
|
|
|
serverTime: startUploadDate.timeIntervalSince1970,
|
|
|
|
newestTime: data.map(\.toTime).max()
|
|
|
|
)
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
guard allowUploadTrigger(for: type) else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
|
|
|
|
}
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
|
|
|
|
try await uploadSleep(data)
|
|
|
|
let latestUploadedTimestamp = data.map(\.toTime).max()
|
|
|
|
notifyFlutterUpload(
|
|
|
|
type: type,
|
|
|
|
timestamp: data.map(\.toTime).max() ?? endUploadDate.timeIntervalSince1970
|
|
|
|
timestamp: latestUploadedTimestamp!
|
|
|
|
)
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: true,
|
|
|
|
uploadedCount: data.count,
|
|
|
|
errorMessage: nil,
|
|
|
|
latestUploadedTimestamp: latestUploadedTimestamp,
|
|
|
|
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
|
|
|
|
)
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
|
|
|
|
default:
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] reading common data")
|
|
|
|
let data = try await fetchCommonData(
|
|
|
|
type: type,
|
|
|
|
startDate: startUploadDate,
|
|
...
|
...
|
@@ -282,30 +395,28 @@ private extension NativeHealthDataUploader { |
|
|
|
)
|
|
|
|
.filter { $0.time > startUploadDate.timeIntervalSince1970 }
|
|
|
|
.sorted { $0.time < $1.time }
|
|
|
|
debugLogTimeComparison(
|
|
|
|
type: type,
|
|
|
|
serverTime: startUploadDate.timeIntervalSince1970,
|
|
|
|
newestTime: data.map(\.time).max()
|
|
|
|
)
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
guard allowUploadTrigger(for: type) else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
|
|
|
|
}
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
|
|
|
|
try await uploadCommon(data)
|
|
|
|
let latestUploadedTimestamp = data.map(\.time).max()
|
|
|
|
notifyFlutterUpload(
|
|
|
|
type: type,
|
|
|
|
timestamp: data.map(\.time).max() ?? endUploadDate.timeIntervalSince1970
|
|
|
|
timestamp: latestUploadedTimestamp!
|
|
|
|
)
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: true,
|
|
|
|
uploadedCount: data.count,
|
|
|
|
errorMessage: nil,
|
|
|
|
latestUploadedTimestamp: latestUploadedTimestamp,
|
|
|
|
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
|
|
|
|
)
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
...
|
...
|
@@ -314,6 +425,17 @@ private extension NativeHealthDataUploader { |
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func commitUploadTimestamps(_ timestamps: [NativeHealthDataType: TimeInterval]) {
|
|
|
|
let grouped = timestamps.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, item in
|
|
|
|
let anchorType = Self.uploadAnchorType(for: item.key)
|
|
|
|
result[anchorType] = max(result[anchorType] ?? 0, item.value)
|
|
|
|
}
|
|
|
|
grouped.forEach { anchorType, timestamp in
|
|
|
|
let currentTimestamp = syncStore.lastSyncDate(for: anchorType)?.timeIntervalSince1970 ?? 0
|
|
|
|
syncStore.save(date: Date(timeIntervalSince1970: max(currentTimestamp, timestamp)), for: anchorType)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func fetchCommonData(
|
|
|
|
type: NativeHealthDataType,
|
|
|
|
startDate: Date,
|
|
...
|
...
|
@@ -356,28 +478,38 @@ private extension NativeHealthDataUploader { |
|
|
|
uploadTimeList: NativeHealthUploadTimeList,
|
|
|
|
service: HealthKitService
|
|
|
|
) async -> UploadTaskResult {
|
|
|
|
let startDate = uploadTimeList.latestDataTime(for: .activeEnergy)
|
|
|
|
?? Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
|
|
|
|
let calendar = Calendar.current
|
|
|
|
let recentStartDate = calendar.startOfDay(
|
|
|
|
for: calendar.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
|
|
|
|
)
|
|
|
|
let startDate = uploadTimeList.latestDataTime(for: Self.activityTargetAnchorType) ?? recentStartDate
|
|
|
|
let endDate = Date()
|
|
|
|
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))"
|
|
|
|
)
|
|
|
|
do {
|
|
|
|
guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate),
|
|
|
|
target.move != nil || target.stand != nil else {
|
|
|
|
DebugLogger.debugLog("[activityTarget] no target data")
|
|
|
|
let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
|
|
|
|
guard let target = targets.last,
|
|
|
|
targets.contains(where: { $0.move != nil || $0.stand != nil }) else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
DebugLogger.debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")
|
|
|
|
try await uploadActivityTarget(target)
|
|
|
|
let uploadedTypes = try await uploadActivityTargetHealthValues(target)
|
|
|
|
let uploadedTypes = try await uploadActivityTargetHealthValues(targets)
|
|
|
|
uploadedTypes.forEach {
|
|
|
|
notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp)
|
|
|
|
}
|
|
|
|
DebugLogger.debugLog("[activityTarget] upload success")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil)
|
|
|
|
let latestTimestamp = uploadedTypes.map(\.timestamp).max()
|
|
|
|
let latestUploadedTimestamps = latestTimestamp.map { timestamp in
|
|
|
|
Self.activityTargetTimestampTypes.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, type in
|
|
|
|
result[type] = timestamp
|
|
|
|
}
|
|
|
|
} ?? [:]
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: true,
|
|
|
|
uploadedCount: uploadedTypes.isEmpty ? 0 : 1,
|
|
|
|
errorMessage: nil,
|
|
|
|
latestUploadedTimestamp: latestTimestamp,
|
|
|
|
latestUploadedTimestamps: latestUploadedTimestamps
|
|
|
|
)
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
|
|
|
|
return UploadTaskResult(
|
|
...
|
...
|
@@ -391,35 +523,21 @@ private extension NativeHealthDataUploader { |
|
|
|
func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
|
|
|
|
let serverTimeList = try await fetchLastUploadTime()
|
|
|
|
var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
|
|
|
|
DebugLogger.debugLog("processLastUploadTime started, source=server")
|
|
|
|
|
|
|
|
let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60))
|
|
|
|
let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970
|
|
|
|
|
|
|
|
for type in NativeHealthDataType.allCases where type != .unknown {
|
|
|
|
let serverTime = serverTimeList.latestTimeInterval(for: type)
|
|
|
|
let finalTime: TimeInterval
|
|
|
|
let sourceDescription: String
|
|
|
|
if type == .sleep {
|
|
|
|
if let serverTime {
|
|
|
|
finalTime = max(0, serverTime - sleepUploadLookback)
|
|
|
|
sourceDescription = "server minus 24h"
|
|
|
|
} else {
|
|
|
|
finalTime = twoYearsAgoMidnight
|
|
|
|
sourceDescription = "missing; first sleep upload uses two years"
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
finalTime = serverTime ?? twoYearsAgoMidnight
|
|
|
|
sourceDescription = serverTime == nil ? "missing; first upload uses two years" : "server"
|
|
|
|
}
|
|
|
|
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[\(type.debugName)] server start time=\(serverTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<missing>"), source=\(sourceDescription), resolved=\(Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: finalTime)))"
|
|
|
|
)
|
|
|
|
let lookbackYears = NativeHealthUploadConfiguration.firstUploadLookbackYears
|
|
|
|
let firstUploadStartDate = Calendar.current.date(byAdding: .year, value: -lookbackYears, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -TimeInterval(lookbackYears * 365 * 24 * 60 * 60))
|
|
|
|
let firstUploadStartTimestamp = Calendar.current.startOfDay(for: firstUploadStartDate).timeIntervalSince1970
|
|
|
|
|
|
|
|
for anchorType in Self.uploadAnchorTypes {
|
|
|
|
let serverTime = Self.uploadTypes(forAnchorType: anchorType)
|
|
|
|
.compactMap { serverTimeList.rawLatestTimeInterval(for: $0) }
|
|
|
|
.max()
|
|
|
|
let finalTime = serverTime ?? firstUploadStartTimestamp
|
|
|
|
|
|
|
|
resultTimeList.append(
|
|
|
|
NativeHealthUploadTimeList.HealthUploadTime(
|
|
|
|
dataType: type,
|
|
|
|
dataType: anchorType,
|
|
|
|
latestDataTime: finalTime
|
|
|
|
)
|
|
|
|
)
|
|
...
|
...
|
@@ -443,15 +561,13 @@ private extension NativeHealthDataUploader { |
|
|
|
] as [String: Any]
|
|
|
|
}
|
|
|
|
try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
|
|
|
|
recordDebugUploadedCommonData(batch)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {
|
|
|
|
if let lastDate = lastUploadTriggerDates[type],
|
|
|
|
now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[\(type.debugName)] skipped by \(Int(NativeHealthUploadConfiguration.minimumTriggerInterval))s process throttle"
|
|
|
|
)
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
lastUploadTriggerDates[type] = now
|
|
...
|
...
|
@@ -460,7 +576,6 @@ private extension NativeHealthDataUploader { |
|
|
|
|
|
|
|
func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
|
|
|
|
let seconds = Int64(timestamp)
|
|
|
|
DebugLogger.debugLog("[\(type.debugName)] notifying Flutter, dataType=\(type.rawValue), timestamp=\(seconds)")
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
name: .nativeHealthDataDidUpload,
|
|
|
|
object: nil,
|
|
...
|
...
|
@@ -486,6 +601,7 @@ private extension NativeHealthDataUploader { |
|
|
|
] as [String: Any]
|
|
|
|
}
|
|
|
|
try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
|
|
|
|
recordDebugUploadedSleepData(batch)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -494,22 +610,35 @@ private extension NativeHealthDataUploader { |
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTargetHealthValues(
|
|
|
|
_ target: NativeActivityTarget,
|
|
|
|
timestamp: TimeInterval = Date().timeIntervalSince1970
|
|
|
|
_ target: NativeActivityTarget
|
|
|
|
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
|
|
|
|
try await uploadActivityTargetHealthValues([target])
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTargetHealthValues(
|
|
|
|
_ targets: [NativeActivityTarget]
|
|
|
|
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
|
|
|
|
let values: [(NativeHealthDataType, Double?)] = [
|
|
|
|
(.activeEnergy, target.activeEnergyBurned),
|
|
|
|
(.exercise, target.appleExerciseTime),
|
|
|
|
(.stand, target.appleStandHours),
|
|
|
|
]
|
|
|
|
let data = values.compactMap { item -> NativeHealthDataPoint? in
|
|
|
|
let (type, value) = item
|
|
|
|
guard let value else { return nil }
|
|
|
|
return NativeHealthDataPoint(dataType: type, time: timestamp, value: value)
|
|
|
|
var data: [NativeHealthDataPoint] = []
|
|
|
|
for target in targets {
|
|
|
|
guard let timestamp = target.healthValueTimestamp else { continue }
|
|
|
|
let values: [(NativeHealthDataType, Double?)] = [
|
|
|
|
(.activeEnergy, target.activeEnergyBurned),
|
|
|
|
(.exercise, target.appleExerciseTime),
|
|
|
|
(.stand, target.appleStandHours),
|
|
|
|
]
|
|
|
|
for (type, value) in values {
|
|
|
|
guard let value else { continue }
|
|
|
|
data.append(
|
|
|
|
NativeHealthDataPoint(
|
|
|
|
dataType: type,
|
|
|
|
time: timestamp,
|
|
|
|
value: value
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
guard !data.isEmpty else { return [] }
|
|
|
|
|
|
|
|
DebugLogger.debugLog("[activityTarget] uploading common values, count=\(data.count)")
|
|
|
|
try await uploadCommon(data)
|
|
|
|
return data.map { ($0.dataType, $0.time) }
|
|
|
|
}
|
|
...
|
...
|
@@ -546,6 +675,11 @@ private extension NativeHealthDataUploader { |
|
|
|
throw NativeHealthUploadError.invalidResponse
|
|
|
|
}
|
|
|
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
|
|
|
if httpResponse.statusCode == 401 {
|
|
|
|
await MainActor.run {
|
|
|
|
AppShared.shared.logout()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw NativeHealthUploadError.requestFailed(
|
|
|
|
path: path,
|
|
|
|
statusCode: httpResponse.statusCode,
|
|
...
|
...
|
@@ -566,17 +700,50 @@ private extension NativeHealthDataUploader { |
|
|
|
Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
|
|
|
|
}
|
|
|
|
|
|
|
|
func debugLogTimeComparison(
|
|
|
|
type: NativeHealthDataType,
|
|
|
|
serverTime: TimeInterval,
|
|
|
|
newestTime: TimeInterval?
|
|
|
|
) {
|
|
|
|
let newestDescription = newestTime.map {
|
|
|
|
"\(debugTimestamp($0)) (unix=\(Int64($0)))"
|
|
|
|
} ?? "<none>"
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[iOS][time-check] dataType=\(type.rawValue)(\(type.debugName)), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
|
|
|
|
)
|
|
|
|
func recordDebugUploadedCommonData(_ data: [NativeHealthDataPoint]) {
|
|
|
|
let points = data.map {
|
|
|
|
NativeHealthDebugUploadedDataPoint(
|
|
|
|
dataType: $0.dataType,
|
|
|
|
dataTypeRawValue: $0.dataType.rawValue,
|
|
|
|
dataTypeName: $0.dataType.debugName,
|
|
|
|
value: $0.value,
|
|
|
|
timestamp: $0.time
|
|
|
|
)
|
|
|
|
}
|
|
|
|
Self.debugUploadedDataStore.append(points)
|
|
|
|
}
|
|
|
|
|
|
|
|
func recordDebugUploadedSleepData(_ data: [NativeSleepInterval]) {
|
|
|
|
let points = data.map {
|
|
|
|
NativeHealthDebugUploadedDataPoint(
|
|
|
|
dataType: .sleep,
|
|
|
|
dataTypeRawValue: NativeHealthDataType.sleep.rawValue,
|
|
|
|
dataTypeName: NativeHealthDataType.sleep.debugName,
|
|
|
|
value: Double($0.dataType),
|
|
|
|
timestamp: $0.toTime
|
|
|
|
)
|
|
|
|
}
|
|
|
|
Self.debugUploadedDataStore.append(points)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
private final class NativeHealthDebugUploadedDataStore: @unchecked Sendable {
|
|
|
|
private let lock = NSLock()
|
|
|
|
private var points: [NativeHealthDebugUploadedDataPoint] = []
|
|
|
|
|
|
|
|
func append(_ newPoints: [NativeHealthDebugUploadedDataPoint]) {
|
|
|
|
guard !newPoints.isEmpty else { return }
|
|
|
|
lock.lock()
|
|
|
|
points.append(contentsOf: newPoints)
|
|
|
|
lock.unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
func snapshot() -> [NativeHealthDebugUploadedDataPoint] {
|
|
|
|
lock.lock()
|
|
|
|
let currentPoints = points
|
|
|
|
lock.unlock()
|
|
|
|
return currentPoints
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -602,6 +769,10 @@ struct NativeHealthUploadTimeList: Codable { |
|
|
|
}
|
|
|
|
|
|
|
|
func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
|
|
|
|
rawLatestTimeInterval(for: NativeHealthDataUploader.uploadAnchorType(for: type))
|
|
|
|
}
|
|
|
|
|
|
|
|
func rawLatestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
|
|
|
|
latestDataTimeList.first { $0.dataType == type }?.latestDataTime
|
|
|
|
}
|
|
|
|
|
...
|
...
|
|