|
|
|
import Foundation
|
|
|
|
|
|
|
|
struct NativeHealthUploadSummary {
|
|
|
|
let commonUploadSuccess: Bool
|
|
|
|
let sleepUploadSuccess: Bool
|
|
|
|
let errorMessage: String?
|
|
|
|
let commonCount: Int
|
|
|
|
let sleepCount: Int
|
|
|
|
}
|
|
|
|
|
|
|
|
enum NativeHealthUploadError: LocalizedError {
|
|
|
|
case invalidServerURL
|
|
|
|
case missingAccessToken
|
|
|
|
case invalidResponse
|
|
|
|
case requestFailed(path: String, statusCode: Int, body: String?)
|
|
|
|
|
|
|
|
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)" } ?? "")"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Uploads Apple Health data to DoubleFeel.
|
|
|
|
///
|
|
|
|
/// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only
|
|
|
|
/// owns upload requests and the per-user last-upload-time cache migrated from
|
|
|
|
/// the original SwiftUI app.
|
|
|
|
final class NativeHealthDataUploader {
|
|
|
|
static let shared = NativeHealthDataUploader()
|
|
|
|
|
|
|
|
private let session: URLSession
|
|
|
|
private var uploadTimeRecorder: NativeHealthUploadTimeRecorder
|
|
|
|
|
|
|
|
private let maxUploadTimeWeek = 4
|
|
|
|
private let defaultUploadTimeWeek = 1
|
|
|
|
private let firstUploadYear = 2
|
|
|
|
|
|
|
|
init(
|
|
|
|
session: URLSession = .shared,
|
|
|
|
uploadTimeRecorder: NativeHealthUploadTimeRecorder = NativeHealthUploadTimeRecorder()
|
|
|
|
) {
|
|
|
|
self.session = session
|
|
|
|
self.uploadTimeRecorder = uploadTimeRecorder
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
|
|
|
|
var commonCount = 0
|
|
|
|
var sleepCount = 0
|
|
|
|
var commonUploadSuccess = true
|
|
|
|
var sleepUploadSuccess = true
|
|
|
|
var errorMessages: [String] = []
|
|
|
|
|
|
|
|
debugLog("uploadAll started, userId=\(AppShared.shared.userId ?? "<nil>")")
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await processLastUploadTime()
|
|
|
|
|
|
|
|
for type in Self.commonUploadTypes {
|
|
|
|
let result = await upload(type: type, uploadTimeList: uploadTimeList, service: service)
|
|
|
|
commonCount += result.uploadedCount
|
|
|
|
if !result.success {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
if let errorMessage = result.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let sleepResult = await upload(type: .sleep, uploadTimeList: uploadTimeList, service: service)
|
|
|
|
sleepCount = sleepResult.uploadedCount
|
|
|
|
sleepUploadSuccess = sleepResult.success
|
|
|
|
if let errorMessage = sleepResult.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
|
|
|
|
let activityTargetUploaded = await uploadActivityTargetIfNeeded(
|
|
|
|
uploadTimeList: uploadTimeList,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
if !activityTargetUploaded.success {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
if let errorMessage = activityTargetUploaded.errorMessage {
|
|
|
|
errorMessages.append(errorMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
commonUploadSuccess = false
|
|
|
|
sleepUploadSuccess = false
|
|
|
|
errorMessages.append(error.localizedDescription)
|
|
|
|
debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
|
|
|
|
}
|
|
|
|
|
|
|
|
debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
|
|
|
|
|
|
|
|
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 {
|
|
|
|
do {
|
|
|
|
let uploadTimeList = try await processLastUploadTime()
|
|
|
|
return await upload(type: type, uploadTimeList: uploadTimeList, service: service).success
|
|
|
|
} catch {
|
|
|
|
debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
|
|
|
|
print("Health upload failed to process last upload time: \(error.localizedDescription)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private extension NativeHealthDataUploader {
|
|
|
|
struct UploadTaskResult {
|
|
|
|
let success: Bool
|
|
|
|
let uploadedCount: Int
|
|
|
|
let errorMessage: String?
|
|
|
|
}
|
|
|
|
|
|
|
|
static let commonUploadTypes: [NativeHealthDataType] = [
|
|
|
|
.hrv,
|
|
|
|
.heartRate,
|
|
|
|
.walkingHeartRate,
|
|
|
|
.restingHeartRate,
|
|
|
|
.sleepingHeartRate,
|
|
|
|
.oxygenSaturation,
|
|
|
|
.activeEnergy,
|
|
|
|
.exercise,
|
|
|
|
.stand,
|
|
|
|
.steps,
|
|
|
|
.sleepingWristTemperature,
|
|
|
|
.respiratoryRate,
|
|
|
|
.irregularHeartRhythm,
|
|
|
|
]
|
|
|
|
|
|
|
|
func upload(
|
|
|
|
type: NativeHealthDataType,
|
|
|
|
uploadTimeList: NativeHealthUploadTimeList,
|
|
|
|
service: HealthKitService
|
|
|
|
) async -> UploadTaskResult {
|
|
|
|
guard type != .unknown,
|
|
|
|
let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
|
|
|
|
debugLog("[\(type.debugName)] skipped, no upload start date")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
let endUploadDate = Date()
|
|
|
|
debugLog(
|
|
|
|
"[\(type.debugName)] upload started, range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
|
|
|
|
)
|
|
|
|
guard endUploadDate >= startUploadDate else {
|
|
|
|
debugLog("[\(type.debugName)] upload failed, start date is later than end date")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "健康数据上传开始时间晚于结束时间:\(type)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
switch type {
|
|
|
|
case .sleep:
|
|
|
|
debugLog("[\(type.debugName)] reading sleep data")
|
|
|
|
let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
|
|
|
|
debugLog("[\(type.debugName)] read finished, count=\(data.count)")
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
uploadTimeRecorder.save(date: endUploadDate, for: type)
|
|
|
|
debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
debugLog("[\(type.debugName)] uploading, count=\(data.count)")
|
|
|
|
try await uploadSleep(data)
|
|
|
|
uploadTimeRecorder.save(date: endUploadDate, for: type)
|
|
|
|
debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
|
|
|
|
default:
|
|
|
|
debugLog("[\(type.debugName)] reading common data")
|
|
|
|
let data = try await fetchCommonData(
|
|
|
|
type: type,
|
|
|
|
startDate: startUploadDate,
|
|
|
|
endDate: endUploadDate,
|
|
|
|
service: service
|
|
|
|
)
|
|
|
|
debugLog("[\(type.debugName)] read finished, count=\(data.count)")
|
|
|
|
guard !data.isEmpty else {
|
|
|
|
uploadTimeRecorder.save(date: endUploadDate, for: type)
|
|
|
|
debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
debugLog("[\(type.debugName)] uploading, count=\(data.count)")
|
|
|
|
try await uploadCommon(data)
|
|
|
|
uploadTimeRecorder.save(date: endUploadDate, for: type)
|
|
|
|
debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "健康数据上传失败:\(type), \(error.localizedDescription)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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 startDate = uploadTimeList.latestDataTime(for: .activeEnergy)
|
|
|
|
?? Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
|
|
|
|
let endDate = Date()
|
|
|
|
|
|
|
|
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 {
|
|
|
|
debugLog("[activityTarget] no target data")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
|
|
|
|
}
|
|
|
|
debugLog("[activityTarget] uploading, move=\(target.move.map(String.init) ?? "<nil>"), stand=\(target.stand.map(String.init) ?? "<nil>")")
|
|
|
|
try await uploadActivityTarget(target)
|
|
|
|
debugLog("[activityTarget] upload success")
|
|
|
|
return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil)
|
|
|
|
} catch {
|
|
|
|
debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
|
|
|
|
return UploadTaskResult(
|
|
|
|
success: false,
|
|
|
|
uploadedCount: 0,
|
|
|
|
errorMessage: "活动目标上传失败:\(error.localizedDescription)"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
|
|
|
|
let localTimeList = uploadTimeRecorder.records
|
|
|
|
let serverTimeList = try? await fetchLastUploadTime()
|
|
|
|
var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
|
|
|
|
debugLog("processLastUploadTime started, hasServerTimeList=\(serverTimeList != nil)")
|
|
|
|
|
|
|
|
let fourWeeksAgo = Calendar.current.date(byAdding: .weekOfYear, value: -maxUploadTimeWeek, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -TimeInterval(maxUploadTimeWeek * 7 * 24 * 60 * 60))
|
|
|
|
let fourWeeksAgoMidnight = Calendar.current.startOfDay(for: fourWeeksAgo).timeIntervalSince1970
|
|
|
|
|
|
|
|
let oneWeekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
|
|
|
|
?? Date(timeIntervalSinceNow: -TimeInterval(defaultUploadTimeWeek * 7 * 24 * 60 * 60))
|
|
|
|
let oneWeekAgoMidnight = Calendar.current.startOfDay(for: oneWeekAgo).timeIntervalSince1970
|
|
|
|
|
|
|
|
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 localTime = localTimeList.latestTimeInterval(for: type)
|
|
|
|
let serverTime = serverTimeList?.latestTimeInterval(for: type)
|
|
|
|
|
|
|
|
let finalTime: TimeInterval
|
|
|
|
if let localTime, localTime > fourWeeksAgoMidnight {
|
|
|
|
finalTime = localTime
|
|
|
|
} else if let serverTime, serverTime > fourWeeksAgoMidnight {
|
|
|
|
finalTime = serverTime
|
|
|
|
} else if localTime != nil || serverTime != nil {
|
|
|
|
finalTime = oneWeekAgoMidnight
|
|
|
|
} else {
|
|
|
|
finalTime = twoYearsAgoMidnight
|
|
|
|
}
|
|
|
|
|
|
|
|
debugLog(
|
|
|
|
"[\(type.debugName)] resolved start time=\(Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: finalTime))), local=\(localTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<nil>"), server=\(serverTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<nil>")"
|
|
|
|
)
|
|
|
|
resultTimeList.append(
|
|
|
|
NativeHealthUploadTimeList.HealthUploadTime(
|
|
|
|
dataType: type,
|
|
|
|
latestDataTime: finalTime
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
return NativeHealthUploadTimeList(latestDataTimeList: resultTimeList)
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadCommon(_ data: [NativeHealthDataPoint]) async throws {
|
|
|
|
let list = data.map {
|
|
|
|
[
|
|
|
|
"data_type": $0.dataType.rawValue,
|
|
|
|
"time": $0.time,
|
|
|
|
"value": $0.value,
|
|
|
|
] as [String: Any]
|
|
|
|
}
|
|
|
|
try await request(path: "/client/doublefeel/health/data_upload/common/", method: "POST", body: ["data_list": list])
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadSleep(_ data: [NativeSleepInterval]) async throws {
|
|
|
|
let list = data.map {
|
|
|
|
[
|
|
|
|
"data_type": $0.dataType,
|
|
|
|
"from_time": $0.fromTime,
|
|
|
|
"to_time": $0.toTime,
|
|
|
|
] as [String: Any]
|
|
|
|
}
|
|
|
|
try await request(path: "/client/doublefeel/health/data_upload/sleep/", method: "POST", body: ["data_list": list])
|
|
|
|
}
|
|
|
|
|
|
|
|
func uploadActivityTarget(_ target: NativeActivityTarget) async throws {
|
|
|
|
var body: [String: Any] = [:]
|
|
|
|
body["move"] = target.move
|
|
|
|
body["stand"] = target.stand
|
|
|
|
try await request(path: "/client/doublefeel/health/activity_target/", method: "POST", body: body)
|
|
|
|
}
|
|
|
|
|
|
|
|
func fetchLastUploadTime() async throws -> NativeHealthUploadTimeList {
|
|
|
|
let data = try await request(path: "/client/doublefeel/health/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 {
|
|
|
|
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 debugLog(_ message: String) {
|
|
|
|
#if DEBUG || CI_ENV
|
|
|
|
print("[NativeHealthDataUploader] \(message)")
|
|
|
|
#endif
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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? {
|
|
|
|
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 struct HealthUploadTimeListResponse: Decodable {
|
|
|
|
let data: NativeHealthUploadTimeList?
|
|
|
|
}
|
|
|
|
|
|
|
|
struct NativeHealthUploadTimeRecorder {
|
|
|
|
private var cachedRecords: NativeHealthUploadTimeList?
|
|
|
|
private static let cacheKey = "HealthUploadlocalRecord_records"
|
|
|
|
|
|
|
|
var records: NativeHealthUploadTimeList {
|
|
|
|
mutating get {
|
|
|
|
if let cachedRecords {
|
|
|
|
return cachedRecords
|
|
|
|
}
|
|
|
|
let records = Self.loadFromCache() ?? NativeHealthUploadTimeList(latestDataTimeList: [])
|
|
|
|
cachedRecords = records
|
|
|
|
return records
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
cachedRecords = newValue
|
|
|
|
Self.saveToCache(newValue)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
init(records: NativeHealthUploadTimeList? = nil) {
|
|
|
|
cachedRecords = records
|
|
|
|
}
|
|
|
|
|
|
|
|
mutating func save(date: Date, for type: NativeHealthDataType) {
|
|
|
|
var records = records
|
|
|
|
records.latestDataTimeList.removeAll { $0.dataType == type }
|
|
|
|
records.latestDataTimeList.append(
|
|
|
|
NativeHealthUploadTimeList.HealthUploadTime(
|
|
|
|
dataType: type,
|
|
|
|
latestDataTime: date.timeIntervalSince1970
|
|
|
|
)
|
|
|
|
)
|
|
|
|
self.records = records
|
|
|
|
}
|
|
|
|
|
|
|
|
static func clearCache() {
|
|
|
|
UserDefaults.standard.removeObject(forKey: cacheKeyForCurrentUser())
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func loadFromCache() -> NativeHealthUploadTimeList? {
|
|
|
|
guard let data = UserDefaults.standard.data(forKey: cacheKeyForCurrentUser()) else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return try? JSONDecoder().decode(NativeHealthUploadTimeList.self, from: data)
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func saveToCache(_ records: NativeHealthUploadTimeList) {
|
|
|
|
guard let data = try? JSONEncoder().encode(records) else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
UserDefaults.standard.set(data, forKey: cacheKeyForCurrentUser())
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func cacheKeyForCurrentUser() -> String {
|
|
|
|
let userId = AppShared.shared.userId
|
|
|
|
?? AppShared.shared.userSummary?.meUserInfo?.id.map(String.init)
|
|
|
|
?? AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.userId)
|
|
|
|
?? ""
|
|
|
|
return cacheKey + userId
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|