|
|
|
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 {
|
|
|
|
let commonUploadSuccess: Bool
|
|
|
|
let sleepUploadSuccess: Bool
|
|
|
|
let errorMessage: String?
|
|
|
|
let commonCount: Int
|
|
|
|
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 {
|
|
|
|
case .invalidServerURL:
|
|
|
|
return "健康数据上传地址无效"
|
|
|
|
case .missingAccessToken:
|
|
|
|
return "缺少登录态,无法上传健康数据"
|
|
|
|
case .invalidResponse:
|
|
|
|
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 编解码失败"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Uploads Apple Health data to DoubleFeel.
|
|
|
|
///
|
|
|
|
/// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only
|
|
|
|
/// owns upload requests. Upload boundaries always come from the server's
|
|
|
|
/// `/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 uploadBatchSize = 500
|
|
|
|
private let syncStore = HealthSyncStateStore()
|
|
|
|
private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
|
|
|
|
|
|
|
|
init(session: URLSession = .shared) {
|
|
|
|
self.session = session
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
let message = NativeHealthUploadError.missingAccessToken.localizedDescription
|
|
|
|
return NativeHealthUploadSummary(
|
|
|
|
commonUploadSuccess: false,
|
|
|
|
sleepUploadSuccess: false,
|
|
|
|
errorMessage: message,
|
|
|
|
commonCount: 0,
|
|
|
|
sleepCount: 0
|
|
|
|
)
|
|
|
|
}
|
|
|
|
var commonCount = 0
|
|
|
|
var sleepCount = 0
|
|
|
|
var commonUploadSuccess = true
|
|
|
|
var sleepUploadSuccess = true
|
|
|
|
var errorMessages: [String] = []
|
|
|
|
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await resolvedUploadTimeList()
|
|
|
|
|
|
|
|
for type in Self.commonUploadTypes {
|
|
|
|
let result = await upload(
|
|
|
|
type: type,
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
trigger: .full,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
commonCount += result.uploadedCount
|
|
|
|
if !result.success {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
if let errorMessage = result.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let sleepResult = await upload(
|
|
|
|
type: .sleep,
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
trigger: .full,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(sleepResult.latestUploadedTimestamps)
|
|
|
|
sleepCount = sleepResult.uploadedCount
|
|
|
|
sleepUploadSuccess = sleepResult.success
|
|
|
|
if let errorMessage = sleepResult.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
|
|
|
|
let activityTargetUploaded = await uploadActivityTargetIfNeeded(
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(activityTargetUploaded.latestUploadedTimestamps)
|
|
|
|
if !activityTargetUploaded.success {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
if let errorMessage = activityTargetUploaded.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
sleepUploadSuccess = false
|
|
|
|
errorMessages.append(error.localizedDescription)
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=uploadAll error=\(error.localizedDescription)")
|
|
|
|
}
|
|
|
|
|
|
|
|
return NativeHealthUploadSummary(
|
|
|
|
commonUploadSuccess: commonUploadSuccess,
|
|
|
|
sleepUploadSuccess: sleepUploadSuccess,
|
|
|
|
errorMessage: errorMessages.isEmpty ? nil : errorMessages.joined(separator: "\n"),
|
|
|
|
commonCount: commonCount,
|
|
|
|
sleepCount: sleepCount
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await resolvedUploadTimeList()
|
|
|
|
let result = await upload(
|
|
|
|
type: type,
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
trigger: .manual,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
commitUploadTimestamps(result.latestUploadedTimestamps)
|
|
|
|
return result.success
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadObservedChange(
|
|
|
|
types: [NativeHealthDataType],
|
|
|
|
includeActivityTarget: Bool,
|
|
|
|
observedSampleTypeIdentifier: String? = nil,
|
|
|
|
service: HealthKitService = .shared
|
|
|
|
) async -> ObservedUploadResult {
|
|
|
|
guard AppShared.shared.token?.isEmpty == false else {
|
|
|
|
return ObservedUploadResult(success: false, latestTimestamps: [:])
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
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,
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
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 || 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 ObservedUploadResult(success: success, latestTimestamps: latestTimestamps)
|
|
|
|
} catch {
|
|
|
|
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 {
|
|
|
|
struct UploadTaskResult {
|
|
|
|
let success: Bool
|
|
|
|
let uploadedCount: Int
|
|
|
|
let errorMessage: String?
|
|
|
|
var latestUploadedTimestamp: TimeInterval?
|
|
|
|
var latestUploadedTimestamps: [NativeHealthDataType: TimeInterval] = [:]
|
|
|
|
var wasThrottled = false
|
|
|
|
}
|
|
|
|
|
|
|
|
enum UploadTrigger: String {
|
|
|
|
case full
|
|
|
|
case observer
|
|
|
|
case manual
|
|
|
|
}
|
|
|
|
|
|
|
|
static let commonUploadTypes: [NativeHealthDataType] = [
|
|
|
|
.hrv,
|
|
|
|
.heartRate,
|
|
|
|
.walkingHeartRate,
|
|
|
|
.restingHeartRate,
|
|
|
|
.sleepingHeartRate,
|
|
|
|
.oxygenSaturation,
|
|
|
|
.activeEnergy,
|
|
|
|
.exercise,
|
|
|
|
.stand,
|
|
|
|
.steps,
|
|
|
|
.sleepingWristTemperature,
|
|
|
|
.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,
|
|
|
|
uploadTimeList: NativeHealthUploadTimeList,
|
|
|
|
trigger: UploadTrigger,
|
|
|
|
service: HealthKitService
|
|
|
|
) async -> UploadTaskResult {
|
|
|
|
guard type != .unknown,
|
|
|
|
let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
|
|
|
|
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()
|
|
|
|
guard endUploadDate >= startUploadDate else {
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=start date is later than end date")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "健康数据上传开始时间晚于结束时间:\(type)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
switch type {
|
|
|
|
case .sleep:
|
|
|
|
let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
|
|
|
|
.filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
|
|
|
|
.sorted { $0.toTime < $1.toTime }
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
guard allowUploadTrigger(for: type) else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
|
|
|
|
}
|
|
|
|
try await uploadSleep(data)
|
|
|
|
let latestUploadedTimestamp = data.map(\.toTime).max()
|
|
|
|
notifyFlutterUpload(
|
|
|
|
type: type,
|
|
|
|
timestamp: latestUploadedTimestamp!
|
|
|
|
)
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: true,
|
|
|
|
uploadedCount: data.count,
|
|
|
|
errorMessage: nil,
|
|
|
|
latestUploadedTimestamp: latestUploadedTimestamp,
|
|
|
|
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
|
|
|
|
)
|
|
|
|
default:
|
|
|
|
let data = try await fetchCommonData(
|
|
|
|
type: type,
|
|
|
|
startDate: startUploadDate,
|
|
|
|
endDate: endUploadDate,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
.filter { $0.time > startUploadDate.timeIntervalSince1970 }
|
|
|
|
.sorted { $0.time < $1.time }
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
guard allowUploadTrigger(for: type) else {
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
|
|
|
|
}
|
|
|
|
try await uploadCommon(data)
|
|
|
|
let latestUploadedTimestamp = data.map(\.time).max()
|
|
|
|
notifyFlutterUpload(
|
|
|
|
type: type,
|
|
|
|
timestamp: latestUploadedTimestamp!
|
|
|
|
)
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: true,
|
|
|
|
uploadedCount: data.count,
|
|
|
|
errorMessage: nil,
|
|
|
|
latestUploadedTimestamp: latestUploadedTimestamp,
|
|
|
|
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
|
|
|
|
)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "健康数据上传失败:\(type), \(error.localizedDescription)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
endDate: Date,
|
|
|
|
service: HealthKitService
|
|
|
|
) async throws -> [NativeHealthDataPoint] {
|
|
|
|
switch type {
|
|
|
|
case .hrv:
|
|
|
|
return try await service.fetchHrvData(startDate: startDate, endDate: endDate)
|
|
|
|
case .heartRate:
|
|
|
|
return try await service.fetchHeartRateData(startDate: startDate, endDate: endDate)
|
|
|
|
case .walkingHeartRate:
|
|
|
|
return try await service.fetchWalkingHeartRateData(startDate: startDate, endDate: endDate)
|
|
|
|
case .restingHeartRate:
|
|
|
|
return try await service.fetchRestingHeartRateData(startDate: startDate, endDate: endDate)
|
|
|
|
case .sleepingHeartRate:
|
|
|
|
return try await service.fetchSleepingHeartRateData(startDate: startDate, endDate: endDate)
|
|
|
|
case .oxygenSaturation:
|
|
|
|
return try await service.fetchOxygenSaturationData(startDate: startDate, endDate: endDate)
|
|
|
|
case .activeEnergy:
|
|
|
|
return try await service.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
|
|
|
|
case .exercise:
|
|
|
|
return try await service.fetchExerciseData(startDate: startDate, endDate: endDate)
|
|
|
|
case .stand:
|
|
|
|
return try await service.fetchStandData(startDate: startDate, endDate: endDate)
|
|
|
|
case .steps:
|
|
|
|
return try await service.fetchStepCountData(startDate: startDate, endDate: endDate)
|
|
|
|
case .sleepingWristTemperature:
|
|
|
|
return try await service.fetchSleepingWristTemperatureData(startDate: startDate, endDate: endDate)
|
|
|
|
case .respiratoryRate:
|
|
|
|
return try await service.fetchRespiratoryRateData(startDate: startDate, endDate: endDate)
|
|
|
|
case .irregularHeartRhythm:
|
|
|
|
return try await service.fetchIrregularHeartRhythmData(startDate: startDate, endDate: endDate)
|
|
|
|
case .unknown, .sleep:
|
|
|
|
return []
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTargetIfNeeded(
|
|
|
|
uploadTimeList: NativeHealthUploadTimeList,
|
|
|
|
service: HealthKitService
|
|
|
|
) async -> UploadTaskResult {
|
|
|
|
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()
|
|
|
|
|
|
|
|
do {
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
try await uploadActivityTarget(target)
|
|
|
|
let uploadedTypes = try await uploadActivityTargetHealthValues(targets)
|
|
|
|
uploadedTypes.forEach {
|
|
|
|
notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp)
|
|
|
|
}
|
|
|
|
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(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "活动目标上传失败:\(error.localizedDescription)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
|
|
|
|
let serverTimeList = try await fetchLastUploadTime()
|
|
|
|
var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
|
|
|
|
|
|
|
|
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: anchorType,
|
|
|
|
latestDataTime: finalTime
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
return NativeHealthUploadTimeList(latestDataTimeList: resultTimeList)
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadCommon(_ data: [NativeHealthDataPoint]) async throws {
|
|
|
|
for batch in data.chunked(into: uploadBatchSize) {
|
|
|
|
batch.forEach { point in
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[iOS][upload] dataType=\(point.dataType.rawValue)(\(point.dataType.debugName)), time=\(debugTimestamp(point.time)), unix=\(Int64(point.time)), value=\(point.value)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
let list = batch.map {
|
|
|
|
[
|
|
|
|
"data_type": $0.dataType.rawValue,
|
|
|
|
"time": $0.time,
|
|
|
|
"value": $0.value,
|
|
|
|
] 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 {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
lastUploadTriggerDates[type] = now
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
|
|
|
|
let seconds = Int64(timestamp)
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
name: .nativeHealthDataDidUpload,
|
|
|
|
object: nil,
|
|
|
|
userInfo: [
|
|
|
|
"dataType": type.rawValue,
|
|
|
|
"timestamp": seconds,
|
|
|
|
]
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadSleep(_ data: [NativeSleepInterval]) async throws {
|
|
|
|
for batch in data.chunked(into: uploadBatchSize) {
|
|
|
|
batch.forEach { interval in
|
|
|
|
DebugLogger.debugLog(
|
|
|
|
"[iOS][upload][sleep] dataType=\(interval.dataType), from=\(debugTimestamp(interval.fromTime)), fromUnix=\(Int64(interval.fromTime)), to=\(debugTimestamp(interval.toTime)), toUnix=\(Int64(interval.toTime))"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
let list = batch.map {
|
|
|
|
[
|
|
|
|
"data_type": $0.dataType,
|
|
|
|
"from_time": $0.fromTime,
|
|
|
|
"to_time": $0.toTime,
|
|
|
|
] as [String: Any]
|
|
|
|
}
|
|
|
|
try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
|
|
|
|
recordDebugUploadedSleepData(batch)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTarget(_ target: NativeActivityTarget) async throws {
|
|
|
|
try await request(path: "/client/doublefeel/health/v2/activity_target/", method: "POST", body: target.uploadBody)
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTargetHealthValues(
|
|
|
|
_ target: NativeActivityTarget
|
|
|
|
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
|
|
|
|
try await uploadActivityTargetHealthValues([target])
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTargetHealthValues(
|
|
|
|
_ targets: [NativeActivityTarget]
|
|
|
|
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
|
|
|
|
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 [] }
|
|
|
|
|
|
|
|
try await uploadCommon(data)
|
|
|
|
return data.map { ($0.dataType, $0.time) }
|
|
|
|
}
|
|
|
|
|
|
|
|
func fetchLastUploadTime() async throws -> NativeHealthUploadTimeList {
|
|
|
|
let data = try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "GET")
|
|
|
|
return try NativeHealthUploadTimeList.decode(from: data)
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func request(path: String, method: String, body: [String: Any]? = nil) async throws -> Data {
|
|
|
|
guard let baseURL = URL(string: AppShared.shared.baseUrl),
|
|
|
|
let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
|
|
|
throw NativeHealthUploadError.invalidServerURL
|
|
|
|
}
|
|
|
|
guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else {
|
|
|
|
throw NativeHealthUploadError.missingAccessToken
|
|
|
|
}
|
|
|
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
request.httpMethod = method
|
|
|
|
request.timeoutInterval = 60
|
|
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
request.setValue(accessToken, forHTTPHeaderField: "access_token")
|
|
|
|
request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent")
|
|
|
|
|
|
|
|
if let body {
|
|
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
}
|
|
|
|
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
|
|
throw NativeHealthUploadError.invalidResponse
|
|
|
|
}
|
|
|
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
|
|
|
if httpResponse.statusCode == 401 {
|
|
|
|
await MainActor.run {
|
|
|
|
AppShared.shared.logout()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw NativeHealthUploadError.requestFailed(
|
|
|
|
path: path,
|
|
|
|
statusCode: httpResponse.statusCode,
|
|
|
|
body: String(data: data, encoding: .utf8)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
|
|
|
|
static let debugDateFormatter: DateFormatter = {
|
|
|
|
let formatter = DateFormatter()
|
|
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
|
|
return formatter
|
|
|
|
}()
|
|
|
|
|
|
|
|
func debugTimestamp(_ timeInterval: TimeInterval) -> String {
|
|
|
|
Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct NativeHealthUploadTimeList: Codable {
|
|
|
|
struct HealthUploadTime: Codable {
|
|
|
|
let dataType: NativeHealthDataType?
|
|
|
|
let latestDataTime: TimeInterval?
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
case dataType = "data_type"
|
|
|
|
case latestDataTime = "latest_data_time"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var latestDataTimeList: [HealthUploadTime]
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
case latestDataTimeList = "latest_data_time_list"
|
|
|
|
}
|
|
|
|
|
|
|
|
func latestDataTime(for type: NativeHealthDataType) -> Date? {
|
|
|
|
latestTimeInterval(for: type).map(Date.init(timeIntervalSince1970:))
|
|
|
|
}
|
|
|
|
|
|
|
|
func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
|
|
|
|
rawLatestTimeInterval(for: NativeHealthDataUploader.uploadAnchorType(for: type))
|
|
|
|
}
|
|
|
|
|
|
|
|
func rawLatestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
|
|
|
|
latestDataTimeList.first { $0.dataType == type }?.latestDataTime
|
|
|
|
}
|
|
|
|
|
|
|
|
static func decode(from data: Data) throws -> NativeHealthUploadTimeList {
|
|
|
|
let decoder = JSONDecoder()
|
|
|
|
|
|
|
|
if let direct = try? decoder.decode(NativeHealthUploadTimeList.self, from: data) {
|
|
|
|
return direct
|
|
|
|
}
|
|
|
|
|
|
|
|
let wrapped = try decoder.decode(HealthUploadTimeListResponse.self, from: data)
|
|
|
|
if let data = wrapped.data {
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
throw NativeHealthUploadError.invalidResponse
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private extension Array {
|
|
|
|
func chunked(into size: Int) -> [[Element]] {
|
|
|
|
guard size > 0 else { return [self] }
|
|
|
|
return stride(from: 0, to: count, by: size).map {
|
|
|
|
Array(self[$0..<Swift.min($0 + size, count)])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private struct HealthUploadTimeListResponse: Decodable {
|
|
|
|
let data: NativeHealthUploadTimeList?
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private extension NativeHealthDataType {
|
|
|
|
var debugName: String {
|
|
|
|
switch self {
|
|
|
|
case .unknown:
|
|
|
|
return "unknown"
|
|
|
|
case .hrv:
|
|
|
|
return "hrv"
|
|
|
|
case .heartRate:
|
|
|
|
return "heartRate"
|
|
|
|
case .oxygenSaturation:
|
|
|
|
return "oxygenSaturation"
|
|
|
|
case .activeEnergy:
|
|
|
|
return "activeEnergy"
|
|
|
|
case .exercise:
|
|
|
|
return "exercise"
|
|
|
|
case .stand:
|
|
|
|
return "stand"
|
|
|
|
case .steps:
|
|
|
|
return "steps"
|
|
|
|
case .walkingHeartRate:
|
|
|
|
return "walkingHeartRate"
|
|
|
|
case .restingHeartRate:
|
|
|
|
return "restingHeartRate"
|
|
|
|
case .sleepingHeartRate:
|
|
|
|
return "sleepingHeartRate"
|
|
|
|
case .sleepingWristTemperature:
|
|
|
|
return "sleepingWristTemperature"
|
|
|
|
case .respiratoryRate:
|
|
|
|
return "respiratoryRate"
|
|
|
|
case .irregularHeartRhythm:
|
|
|
|
return "irregularHeartRhythm"
|
|
|
|
case .sleep:
|
|
|
|
return "sleep"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} |