Commit 94ddfe45f4ae829634f7d9a10ede629b7b210acf

Authored by 权海
1 parent 0493bd04

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

import Foundation
import HealthKit
struct AnchoredHealthCommonReadResult {
let data: [NativeHealthDataPoint]
let anchors: [NativeHealthDataType: Data]
}
struct AnchoredHealthSleepReadResult {
let data: [NativeSleepInterval]
let anchor: Data
}
struct AnchoredHealthActivityAnchorBundle: Codable {
let activeEnergy: Data
let exercise: Data
let stand: Data
}
struct AnchoredHealthActivityReadResult {
let data: [NativeHealthDataPoint]
let targets: [NativeActivityTarget]
let anchors: AnchoredHealthActivityAnchorBundle
}
/// Reads HealthKit changes with HKQueryAnchor and converts them to the same
/// upload payload models used by the existing uploader.
final class AnchoredHealthDataReader {
private let service: HealthKitService
private let anchorStore: AnchoredHealthUploadAnchorStore
private let userIdProvider: () -> Int?
init(
service: HealthKitService = .shared,
anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
) {
self.service = service
self.anchorStore = anchorStore
self.userIdProvider = userIdProvider
}
func readAllCommon() async throws -> AnchoredHealthCommonReadResult {
try await readCommon(dataTypes: nil)
}
func readCommon(
dataTypes requestedTypes: Set<NativeHealthDataType>?
) async throws -> AnchoredHealthCommonReadResult {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
var data: [NativeHealthDataPoint] = []
var anchors: [NativeHealthDataType: Data] = [:]
let types = requestedTypes.map { requestedTypes in
Self.commonDataTypes.filter { requestedTypes.contains($0) }
} ?? Self.commonDataTypes
for type in types {
let storedAnchorData = anchorStore.data(userId: userId, dataType: type)
Self.log(
"reader.common.anchor.before userId=\(userId) dataType=\(type.rawValue) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(firstUploadStartDate().timeIntervalSince1970))"
)
let anchor = anchorStore.anchor(userId: userId, dataType: type)
let changes = try await service.fetchAnchoredChanges(
for: type,
sourceIdentifier: Self.sourceIdentifier(for: type),
anchor: anchor,
initialStartDate: firstUploadStartDate()
)
let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
anchors[type] = archivedAnchor
Self.log(
"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))"
)
guard !changes.samples.isEmpty else { continue }
let startDate = queryStartDate(for: changes.samples)
let endDate = Date()
Self.log(
"reader.common.fetchRange userId=\(userId) dataType=\(type.rawValue) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
)
let points = try await fetchData(type: type, startDate: startDate, endDate: endDate)
Self.log(
"reader.common.data userId=\(userId) dataType=\(type.rawValue) count=\(points.count) range=\(Self.describeCommonRange(points))"
)
Self.logCommonPoints(points, userId: userId, prefix: "reader.common.data.item")
data.append(contentsOf: points)
}
let sortedData = Self.deduplicateCommon(data).sorted { lhs, rhs in
if lhs.time == rhs.time {
return lhs.dataType.rawValue < rhs.dataType.rawValue
}
return lhs.time < rhs.time
}
Self.log(
"reader.common.all userId=\(userId) count=\(sortedData.count) range=\(Self.describeCommonRange(sortedData))"
)
return AnchoredHealthCommonReadResult(
data: sortedData,
anchors: anchors
)
}
func readAllSeelp() async throws -> AnchoredHealthSleepReadResult {
try await readAllSleep()
}
func readActivity() async throws -> AnchoredHealthActivityReadResult {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
let storedAnchors = anchorStore.activityAnchors(userId: userId)
let startLimit = firstUploadStartDate()
let activeEnergyChanges = try await activityAnchoredChanges(
userId: userId,
dataType: .activeEnergy,
storedAnchorData: storedAnchors?.activeEnergy,
initialStartDate: startLimit
)
let exerciseChanges = try await activityAnchoredChanges(
userId: userId,
dataType: .exercise,
storedAnchorData: storedAnchors?.exercise,
initialStartDate: startLimit
)
let standChanges = try await activityAnchoredChanges(
userId: userId,
dataType: .stand,
storedAnchorData: storedAnchors?.stand,
initialStartDate: startLimit
)
let newAnchors = AnchoredHealthActivityAnchorBundle(
activeEnergy: try SharedHealthAnchoredUploadSupport.archive(activeEnergyChanges.newAnchor),
exercise: try SharedHealthAnchoredUploadSupport.archive(exerciseChanges.newAnchor),
stand: try SharedHealthAnchoredUploadSupport.archive(standChanges.newAnchor)
)
let samples = activeEnergyChanges.samples + exerciseChanges.samples + standChanges.samples
guard !samples.isEmpty else {
Self.log(
"reader.activity.all userId=\(userId) count=0 targetCount=0 range=empty anchors=\(Self.describeActivityAnchors(newAnchors))"
)
return AnchoredHealthActivityReadResult(data: [], targets: [], anchors: newAnchors)
}
let startDate = queryStartDate(for: samples)
let endDate = Date()
Self.log(
"reader.activity.fetchRange userId=\(userId) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
)
let points = try await fetchActivityData(startDate: startDate, endDate: endDate)
let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
let sortedPoints = Self.deduplicateCommon(points).sorted { lhs, rhs in
if lhs.time == rhs.time {
return lhs.dataType.rawValue < rhs.dataType.rawValue
}
return lhs.time < rhs.time
}
let sortedTargets = targets.sorted {
($0.healthValueTimestamp ?? 0) < ($1.healthValueTimestamp ?? 0)
}
Self.log(
"reader.activity.all userId=\(userId) count=\(sortedPoints.count) targetCount=\(sortedTargets.count) range=\(Self.describeCommonRange(sortedPoints)) targetRange=\(Self.describeActivityTargetRange(sortedTargets)) anchors=\(Self.describeActivityAnchors(newAnchors))"
)
Self.logCommonPoints(sortedPoints, userId: userId, prefix: "reader.activity.data.item")
Self.logActivityTargets(sortedTargets, userId: userId, prefix: "reader.activity.target.item")
return AnchoredHealthActivityReadResult(data: sortedPoints, targets: sortedTargets, anchors: newAnchors)
}
func readAllSleep() async throws -> AnchoredHealthSleepReadResult {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
let type = NativeHealthDataType.sleep
let sleepAnchorKey = AnchoredHealthUploadAnchorStore.sleepAnchorKey
let storedAnchorData = anchorStore.data(userId: userId, anchorKey: sleepAnchorKey)
Self.log(
"reader.sleep.anchor.before userId=\(userId) anchorKey=\(sleepAnchorKey) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(firstUploadStartDate().timeIntervalSince1970))"
)
let anchor = anchorStore.anchor(userId: userId, anchorKey: sleepAnchorKey)
let changes = try await service.fetchAnchoredChanges(
for: type,
sourceIdentifier: Self.sourceIdentifier(for: type),
anchor: anchor,
initialStartDate: firstUploadStartDate()
)
let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
Self.log(
"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))"
)
guard !changes.samples.isEmpty else {
Self.log("reader.sleep.all userId=\(userId) count=0 range=empty")
return AnchoredHealthSleepReadResult(data: [], anchor: archivedAnchor)
}
let startDate = queryStartDate(for: changes.samples)
let endDate = Date()
Self.log(
"reader.sleep.fetchRange userId=\(userId) start=\(Self.debugTimestamp(startDate.timeIntervalSince1970)) end=\(Self.debugTimestamp(endDate.timeIntervalSince1970))"
)
let intervals = try await service.fetchSleepData(startDate: startDate, endDate: endDate)
let sortedIntervals = Self.deduplicateSleep(intervals).sorted {
if $0.toTime == $1.toTime {
return $0.fromTime < $1.fromTime
}
return $0.toTime < $1.toTime
}
Self.log(
"reader.sleep.all userId=\(userId) count=\(sortedIntervals.count) range=\(Self.describeSleepRange(sortedIntervals))"
)
Self.logSleepIntervals(sortedIntervals, userId: userId, prefix: "reader.sleep.data.item")
return AnchoredHealthSleepReadResult(
data: sortedIntervals,
anchor: archivedAnchor
)
}
}
private extension AnchoredHealthDataReader {
static let commonDataTypes: [NativeHealthDataType] = [
.hrv,
.heartRate,
.walkingHeartRate,
.restingHeartRate,
.sleepingHeartRate,
.oxygenSaturation,
.steps,
.sleepingWristTemperature,
.respiratoryRate,
.irregularHeartRhythm,
]
static let activityDataTypes: Set<NativeHealthDataType> = [
.activeEnergy,
.exercise,
.stand,
]
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 queryStartDate(for samples: [HKSample]) -> Date {
let earliest = samples.map(\.startDate).min() ?? firstUploadStartDate()
return max(Calendar.current.startOfDay(for: earliest), firstUploadStartDate())
}
func fetchData(
type: NativeHealthDataType,
startDate: Date,
endDate: Date
) 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 fetchActivityData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
let activeEnergy = try await service.fetchActiveEnergyData(startDate: startDate, endDate: endDate)
let exercise = try await service.fetchExerciseData(startDate: startDate, endDate: endDate)
let stand = try await service.fetchStandData(startDate: startDate, endDate: endDate)
return activeEnergy + exercise + stand
}
func activityAnchoredChanges(
userId: Int,
dataType: NativeHealthDataType,
storedAnchorData: Data?,
initialStartDate: Date
) async throws -> HealthKitService.AnchoredChanges {
Self.log(
"reader.activity.anchor.before userId=\(userId) sourceDataType=\(dataType.rawValue) anchorKey=\(AnchoredHealthUploadAnchorStore.activityAnchorKey) \(Self.describeAnchorData(storedAnchorData)) initialStart=\(Self.debugTimestamp(initialStartDate.timeIntervalSince1970))"
)
let anchor = storedAnchorData.flatMap {
try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: $0)
}
let changes = try await service.fetchAnchoredChanges(
for: dataType,
sourceIdentifier: Self.sourceIdentifier(for: dataType),
anchor: anchor,
initialStartDate: initialStartDate
)
let archivedAnchor = try SharedHealthAnchoredUploadSupport.archive(changes.newAnchor)
Self.log(
"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))"
)
return changes
}
static func sourceIdentifier(for type: NativeHealthDataType) -> String {
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"
}
}
static func deduplicateCommon(_ data: [NativeHealthDataPoint]) -> [NativeHealthDataPoint] {
var seen = Set<String>()
return data.filter { point in
let key = "\(point.dataType.rawValue)-\(point.time)-\(point.value)"
return seen.insert(key).inserted
}
}
static func deduplicateSleep(_ data: [NativeSleepInterval]) -> [NativeSleepInterval] {
var seen = Set<String>()
return data.filter { interval in
let key = "\(interval.dataType)-\(interval.fromTime)-\(interval.toTime)"
return seen.insert(key).inserted
}
}
static func log(_ message: String) {
DebugLogger.debugLog("[ArchUploader] \(message)")
}
static func logCommonPoints(
_ points: [NativeHealthDataPoint],
userId: Int,
prefix: String
) {
points.forEach { point in
log(
"\(prefix) userId=\(userId) dataType=\(point.dataType.rawValue) time=\(debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
)
}
}
static func logSleepIntervals(
_ intervals: [NativeSleepInterval],
userId: Int,
prefix: String
) {
intervals.forEach { interval in
log(
"\(prefix) userId=\(userId) dataType=\(interval.dataType) from=\(debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
)
}
}
static func logActivityTargets(
_ targets: [NativeActivityTarget],
userId: Int,
prefix: String
) {
targets.forEach { target in
let timestamp = target.healthValueTimestamp ?? 0
log(
"\(prefix) userId=\(userId) time=\(debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
)
}
}
static func describeSamples(_ samples: [HKSample]) -> String {
guard !samples.isEmpty else { return "empty" }
let minStart = samples.map(\.startDate).min() ?? .distantPast
let maxEnd = samples.map(\.endDate).max() ?? .distantPast
return "\(debugTimestamp(minStart.timeIntervalSince1970))...\(debugTimestamp(maxEnd.timeIntervalSince1970))"
}
static func describeCommonRange(_ points: [NativeHealthDataPoint]) -> String {
guard let minTime = points.map(\.time).min(),
let maxTime = points.map(\.time).max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeSleepRange(_ intervals: [NativeSleepInterval]) -> String {
guard let minTime = intervals.map(\.fromTime).min(),
let maxTime = intervals.map(\.toTime).max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeActivityTargetRange(_ targets: [NativeActivityTarget]) -> String {
let times = targets.compactMap(\.healthValueTimestamp)
guard let minTime = times.min(), let maxTime = times.max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle) -> String {
[
"activeEnergy=\(describeAnchorData(anchors.activeEnergy))",
"exercise=\(describeAnchorData(anchors.exercise))",
"stand=\(describeAnchorData(anchors.stand))",
].joined(separator: ",")
}
static func describeAnchorData(_ data: Data?) -> String {
guard let data else { return "anchor=none" }
return "anchor=size:\(data.count),hash:\(data.stableDebugHash)"
}
static func debugTimestamp(_ timeInterval: TimeInterval) -> String {
debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
}
static let debugDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}
private extension Data {
var stableDebugHash: String {
let hash = reduce(UInt64(14_695_981_039_346_656_037)) { result, byte in
(result ^ UInt64(byte)).multipliedReportingOverflow(by: 1_099_511_628_211).partialValue
}
return String(hash, radix: 16)
}
}
... ...
import Foundation
import HealthKit
struct AnchoredHealthUploadSummary {
let commonUploadSuccess: Bool
let sleepUploadSuccess: Bool
let errorMessage: String?
let commonCount: Int
let sleepCount: Int
}
struct AnchoredHealthUploadAnchorStore {
static let sleepAnchorKey = "sleep_all"
static let activityAnchorKey = "activity_all"
private let defaults: UserDefaults
private let keyPrefix = "health_upload_query_anchor_all_v1"
init(defaults: UserDefaults? = AppGroupConstants.defaults) {
self.defaults = defaults ?? .standard
}
func anchor(userId: Int, dataType: NativeHealthDataType) -> HKQueryAnchor? {
guard let data = data(userId: userId, dataType: dataType) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
}
func anchor(userId: Int, anchorKey: String) -> HKQueryAnchor? {
guard let data = data(userId: userId, anchorKey: anchorKey) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
}
func activityAnchors(userId: Int) -> AnchoredHealthActivityAnchorBundle? {
guard let data = data(userId: userId, anchorKey: Self.activityAnchorKey) else { return nil }
return try? PropertyListDecoder().decode(AnchoredHealthActivityAnchorBundle.self, from: data)
}
func data(userId: Int, dataType: NativeHealthDataType) -> Data? {
guard userId > 0 else { return nil }
return defaults.data(forKey: key(userId: userId, dataType: dataType))
}
func data(userId: Int, anchorKey: String) -> Data? {
guard userId > 0 else { return nil }
return defaults.data(forKey: key(userId: userId, anchorKey: anchorKey))
}
func save(_ data: Data, userId: Int, dataType: NativeHealthDataType) {
guard userId > 0 else { return }
defaults.set(data, forKey: key(userId: userId, dataType: dataType))
}
func save(_ data: Data, userId: Int, anchorKey: String) {
guard userId > 0 else { return }
defaults.set(data, forKey: key(userId: userId, anchorKey: anchorKey))
}
func saveActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle, userId: Int) throws {
guard userId > 0 else { return }
let data = try PropertyListEncoder().encode(anchors)
save(data, userId: userId, anchorKey: Self.activityAnchorKey)
}
private func key(userId: Int, dataType: NativeHealthDataType) -> String {
"\(keyPrefix).user_\(userId).type_\(dataType.rawValue)"
}
private func key(userId: Int, anchorKey: String) -> String {
"\(keyPrefix).user_\(userId).anchor_\(anchorKey)"
}
}
/// A fully separate upload path for testing the all-types anchored strategy.
actor AnchoredHealthDataUploader {
static let shared = AnchoredHealthDataUploader()
private let session: URLSession
private let reader: AnchoredHealthDataReader
private let anchorStore: AnchoredHealthUploadAnchorStore
private let userIdProvider: () -> Int?
private let uploadBatchSize = 300
private(set) var isUploadingAll = false
private var uploadAllPending = false
private var pendingUploadAllTypes = false
private var pendingUploadDataTypes = Set<NativeHealthDataType>()
private var uploadAllWaiters: [CheckedContinuation<AnchoredHealthUploadSummary, Never>] = []
init(
session: URLSession = .shared,
anchorStore: AnchoredHealthUploadAnchorStore = AnchoredHealthUploadAnchorStore(),
userIdProvider: @escaping () -> Int? = { AppShared.shared.userId }
) {
self.session = session
self.anchorStore = anchorStore
self.userIdProvider = userIdProvider
self.reader = AnchoredHealthDataReader(anchorStore: anchorStore, userIdProvider: userIdProvider)
}
func uploadAll(
dataTypes: Set<NativeHealthDataType>? = nil
) async -> AnchoredHealthUploadSummary {
await runUploadAll(
dataTypes: dataTypes,
queueAnotherRunIfUploading: false
)
}
func uploadAllAfterObservedChange(sampleTypeIdentifiers: Set<String>) async {
let mappedDataTypes = Self.uploadDataTypes(for: sampleTypeIdentifiers)
let shouldFallbackForMissingAnchor: Bool
if let mappedDataTypes,
let userId = userIdProvider(), userId > 0 {
shouldFallbackForMissingAnchor = hasMissingAnchor(dataTypes: mappedDataTypes, userId: userId)
} else {
shouldFallbackForMissingAnchor = mappedDataTypes != nil
}
let dataTypes = shouldFallbackForMissingAnchor ? nil : mappedDataTypes
let sources = sampleTypeIdentifiers.isEmpty
? "unknown"
: sampleTypeIdentifiers.sorted().joined(separator: ",")
if shouldFallbackForMissingAnchor {
Self.log(
"uploader.observer.fallback reason=missingAnchor userId=\(userIdProvider() ?? -1)"
)
}
Self.log(
"uploader.observer.received userId=\(userIdProvider() ?? -1) sources=\(sources) dataTypes=\(Self.describeRequestedDataTypes(dataTypes))"
)
_ = await runUploadAll(
dataTypes: dataTypes,
queueAnotherRunIfUploading: true
)
}
func uploadAllCommon(
dataTypes: Set<NativeHealthDataType>? = nil
) async throws -> Int {
let result = try await reader.readCommon(dataTypes: dataTypes)
Self.log(
"uploader.common.read userId=\(userIdProvider() ?? -1) count=\(result.data.count) range=\(Self.describeCommonRange(result.data)) anchors=\(Self.describeAnchors(result.anchors))"
)
Self.logCommonPoints(result.data, userId: userIdProvider() ?? -1, prefix: "uploader.common.read.item")
return try await uploadCommon(result.data, anchors: result.anchors)
}
func uploadAllSeelp() async throws -> Int {
try await uploadAllSleep()
}
func uploadAllSleep() async throws -> Int {
let result = try await reader.readAllSleep()
Self.log(
"uploader.sleep.read userId=\(userIdProvider() ?? -1) count=\(result.data.count) range=\(Self.describeSleepRange(result.data)) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(result.anchor))"
)
Self.logSleepIntervals(result.data, userId: userIdProvider() ?? -1, prefix: "uploader.sleep.read.item")
return try await uploadSleep(result.data, anchor: result.anchor)
}
func uploadAllActivity() async throws -> Int {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
let result = try await reader.readActivity()
Self.log(
"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))"
)
Self.logCommonPoints(result.data, userId: userId, prefix: "uploader.activity.read.item")
Self.logActivityTargets(result.targets, userId: userId, prefix: "uploader.activity.target.read.item")
let commonCount = try await uploadCommon(result.data, anchors: [:])
try await uploadActivityTargets(result.targets)
try anchorStore.saveActivityAnchors(result.anchors, userId: userId)
Self.log(
"uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.activityAnchorKey) reason=activityFinished \(Self.describeActivityAnchors(result.anchors)) uploadedRange=\(Self.describeCommonRange(result.data)) targetRange=\(Self.describeActivityTargetRange(result.targets))"
)
return commonCount
}
}
private extension AnchoredHealthDataUploader {
static let activityDataTypes: Set<NativeHealthDataType> = [
.activeEnergy,
.exercise,
.stand,
]
static func uploadDataTypes(
for sampleTypeIdentifiers: Set<String>
) -> Set<NativeHealthDataType>? {
guard !sampleTypeIdentifiers.isEmpty else { return nil }
var dataTypes = Set<NativeHealthDataType>()
for identifier in sampleTypeIdentifiers {
switch identifier {
case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:
dataTypes.insert(.hrv)
case HKQuantityTypeIdentifier.heartRate.rawValue:
dataTypes.formUnion([.heartRate, .sleepingHeartRate])
case HKQuantityTypeIdentifier.stepCount.rawValue:
dataTypes.insert(.steps)
case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:
dataTypes.insert(.oxygenSaturation)
case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
dataTypes.insert(.activeEnergy)
case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
dataTypes.insert(.exercise)
case HKQuantityTypeIdentifier.appleStandTime.rawValue:
dataTypes.insert(.stand)
case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
dataTypes.insert(.walkingHeartRate)
case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
dataTypes.insert(.restingHeartRate)
case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
dataTypes.insert(.sleepingWristTemperature)
case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
dataTypes.insert(.respiratoryRate)
case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:
dataTypes.insert(.irregularHeartRhythm)
case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
dataTypes.insert(.sleep)
default:
return nil
}
}
return dataTypes
}
static func describeRequestedDataTypes(
_ dataTypes: Set<NativeHealthDataType>?
) -> String {
dataTypes.map { types in
types.map(\.rawValue).sorted().map(String.init).joined(separator: ",")
} ?? "all"
}
func hasMissingAnchor(dataTypes: Set<NativeHealthDataType>, userId: Int) -> Bool {
dataTypes.contains { dataType in
if dataType == .sleep {
return anchorStore.anchor(
userId: userId,
anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey
) == nil
}
if Self.activityDataTypes.contains(dataType) {
return anchorStore.activityAnchors(userId: userId) == nil
}
return anchorStore.anchor(userId: userId, dataType: dataType) == nil
}
}
func runUploadAll(
dataTypes: Set<NativeHealthDataType>?,
queueAnotherRunIfUploading: Bool
) async -> AnchoredHealthUploadSummary {
if isUploadingAll {
if queueAnotherRunIfUploading {
uploadAllPending = true
if let dataTypes {
if !pendingUploadAllTypes {
pendingUploadDataTypes.formUnion(dataTypes)
}
} else {
pendingUploadAllTypes = true
pendingUploadDataTypes.removeAll()
}
Self.log(
"uploader.all.pending userId=\(userIdProvider() ?? -1) dataTypes=\(Self.describeRequestedDataTypes(dataTypes))"
)
} else {
Self.log("uploader.all.skip reason=alreadyUploading userId=\(userIdProvider() ?? -1)")
}
return await withCheckedContinuation { continuation in
uploadAllWaiters.append(continuation)
}
}
isUploadingAll = true
var summary: AnchoredHealthUploadSummary
var requestedDataTypes = dataTypes
while true {
uploadAllPending = false
pendingUploadAllTypes = false
pendingUploadDataTypes.removeAll()
Self.log(
"uploader.all.start userId=\(userIdProvider() ?? -1) dataTypes=\(Self.describeRequestedDataTypes(requestedDataTypes))"
)
summary = await performUploadAll(dataTypes: requestedDataTypes)
Self.log(
"uploader.all.end userId=\(userIdProvider() ?? -1) commonSuccess=\(summary.commonUploadSuccess) sleepSuccess=\(summary.sleepUploadSuccess) commonCount=\(summary.commonCount) sleepCount=\(summary.sleepCount) error=\(summary.errorMessage ?? "nil")"
)
guard uploadAllPending else { break }
requestedDataTypes = pendingUploadAllTypes ? nil : pendingUploadDataTypes
}
isUploadingAll = false
let waiters = uploadAllWaiters
uploadAllWaiters.removeAll()
waiters.forEach { $0.resume(returning: summary) }
return summary
}
func performUploadAll(
dataTypes: Set<NativeHealthDataType>?
) async -> AnchoredHealthUploadSummary {
guard AppShared.shared.token?.isEmpty == false else {
let message = NativeHealthUploadError.missingAccessToken.localizedDescription
return AnchoredHealthUploadSummary(
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] = []
let commonTypes = dataTypes.map { types in
Set(types.filter {
$0 != .sleep && $0 != .unknown && !Self.activityDataTypes.contains($0)
})
}
let includesCommon = commonTypes == nil || commonTypes?.isEmpty == false
let includesActivity = dataTypes == nil || dataTypes?.contains(where: { Self.activityDataTypes.contains($0) }) == true
let includesSleep = dataTypes == nil || dataTypes?.contains(.sleep) == true
if includesCommon {
do {
commonCount = try await uploadAllCommon(dataTypes: commonTypes)
} catch {
commonUploadSuccess = false
errorMessages.append("common 上传失败:\(error.localizedDescription)")
}
}
if includesActivity {
do {
commonCount += try await uploadAllActivity()
} catch {
commonUploadSuccess = false
errorMessages.append("activity 上传失败:\(error.localizedDescription)")
}
}
if includesSleep {
do {
sleepCount = try await uploadAllSleep()
} catch {
sleepUploadSuccess = false
errorMessages.append("sleep 上传失败:\(error.localizedDescription)")
}
}
return AnchoredHealthUploadSummary(
commonUploadSuccess: commonUploadSuccess,
sleepUploadSuccess: sleepUploadSuccess,
errorMessage: errorMessages.isEmpty ? nil : errorMessages.joined(separator: "\n"),
commonCount: commonCount,
sleepCount: sleepCount
)
}
func uploadCommon(
_ data: [NativeHealthDataPoint],
anchors: [NativeHealthDataType: Data]
) async throws -> Int {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
guard !data.isEmpty else {
Self.log(
"uploader.common.empty userId=\(userId) anchors=\(Self.describeAnchors(anchors))"
)
anchors.forEach { anchorStore.save($0.value, userId: userId, dataType: $0.key) }
anchors.forEach {
Self.log(
"uploader.anchor.saved userId=\(userId) dataType=\($0.key.rawValue) reason=emptyCommon \(Self.describeAnchorData($0.value))"
)
}
return 0
}
Self.log(
"uploader.common.start userId=\(userId) count=\(data.count) range=\(Self.describeCommonRange(data)) batchSize=\(uploadBatchSize) anchors=\(Self.describeAnchors(anchors))"
)
var uploadedCount = 0
var remainingCountByType = anchors.keys.reduce(into: [NativeHealthDataType: Int]()) { result, type in
result[type] = 0
}
data.forEach { point in
remainingCountByType[point.dataType, default: 0] += 1
}
for (batchIndex, batch) in SharedHealthAnchoredUploadSupport.batches(data, size: uploadBatchSize).enumerated() {
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]
)
uploadedCount += batch.count
for point in batch {
remainingCountByType[point.dataType, default: 0] -= 1
}
Self.log(
"uploader.common.batch.success userId=\(userId) batch=\(batchIndex + 1) count=\(batch.count) range=\(Self.describeCommonRange(batch)) uploadedCount=\(uploadedCount)/\(data.count)"
)
saveFinishedAnchors(
anchors,
userId: userId,
finishedTypes: remainingCountByType.filter { $0.value <= 0 }.map(\.key)
)
batch.forEach { point in
DebugLogger.debugLog(
"[ArchUploader] uploader.common.batch.success.item userId=\(userId) dataType=\(point.dataType.rawValue) time=\(Self.debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
)
}
}
return uploadedCount
}
func uploadActivityTargets(_ targets: [NativeActivityTarget]) async throws {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
guard !targets.isEmpty else {
Self.log("uploader.activityTarget.empty userId=\(userId)")
return
}
for (index, target) in targets.enumerated() {
try await request(
path: "/client/doublefeel/health/v2/activity_target/",
method: "POST",
body: target.uploadBody
)
let timestamp = target.healthValueTimestamp ?? 0
Self.log(
"uploader.activityTarget.success userId=\(userId) index=\(index + 1)/\(targets.count) time=\(Self.debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
)
}
}
func uploadSleep(
_ data: [NativeSleepInterval],
anchor: Data
) async throws -> Int {
guard let userId = userIdProvider(), userId > 0 else {
throw NativeHealthUploadError.missingUserId
}
guard !data.isEmpty else {
Self.log(
"uploader.sleep.empty userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
)
anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
Self.log(
"uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=emptySleep \(Self.describeAnchorData(anchor))"
)
return 0
}
Self.log(
"uploader.sleep.start userId=\(userId) count=\(data.count) range=\(Self.describeSleepRange(data)) batchSize=\(uploadBatchSize) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) \(Self.describeAnchorData(anchor))"
)
var uploadedCount = 0
for (batchIndex, batch) in SharedHealthAnchoredUploadSupport.batches(data, size: uploadBatchSize).enumerated() {
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]
)
uploadedCount += batch.count
Self.log(
"uploader.sleep.batch.success userId=\(userId) batch=\(batchIndex + 1) count=\(batch.count) range=\(Self.describeSleepRange(batch)) uploadedCount=\(uploadedCount)/\(data.count)"
)
if uploadedCount == data.count {
anchorStore.save(anchor, userId: userId, anchorKey: AnchoredHealthUploadAnchorStore.sleepAnchorKey)
Self.log(
"uploader.anchor.saved userId=\(userId) anchorKey=\(AnchoredHealthUploadAnchorStore.sleepAnchorKey) reason=sleepFinished \(Self.describeAnchorData(anchor)) uploadedRange=\(Self.describeSleepRange(data))"
)
}
batch.forEach { interval in
DebugLogger.debugLog(
"[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))"
)
}
}
return uploadedCount
}
func saveFinishedAnchors(
_ anchors: [NativeHealthDataType: Data],
userId: Int,
finishedTypes: [NativeHealthDataType]
) {
for type in finishedTypes {
guard let anchor = anchors[type] else { continue }
anchorStore.save(anchor, userId: userId, dataType: type)
Self.log(
"uploader.anchor.saved userId=\(userId) dataType=\(type.rawValue) reason=commonTypeFinished \(Self.describeAnchorData(anchor))"
)
}
}
@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 func debugTimestamp(_ timeInterval: TimeInterval) -> String {
Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
}
static func log(_ message: String) {
DebugLogger.debugLog("[ArchUploader] \(message)")
}
static func logCommonPoints(
_ points: [NativeHealthDataPoint],
userId: Int,
prefix: String
) {
points.forEach { point in
log(
"\(prefix) userId=\(userId) dataType=\(point.dataType.rawValue) time=\(debugTimestamp(point.time)) unix=\(Int64(point.time)) value=\(point.value)"
)
}
}
static func logSleepIntervals(
_ intervals: [NativeSleepInterval],
userId: Int,
prefix: String
) {
intervals.forEach { interval in
log(
"\(prefix) userId=\(userId) dataType=\(interval.dataType) from=\(debugTimestamp(interval.fromTime)) fromUnix=\(Int64(interval.fromTime)) to=\(debugTimestamp(interval.toTime)) toUnix=\(Int64(interval.toTime))"
)
}
}
static func logActivityTargets(
_ targets: [NativeActivityTarget],
userId: Int,
prefix: String
) {
targets.forEach { target in
let timestamp = target.healthValueTimestamp ?? 0
log(
"\(prefix) userId=\(userId) time=\(debugTimestamp(timestamp)) unix=\(Int64(timestamp)) body=\(target.uploadBodyForDebug)"
)
}
}
static func describeCommonRange(_ points: [NativeHealthDataPoint]) -> String {
guard let minTime = points.map(\.time).min(),
let maxTime = points.map(\.time).max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeSleepRange(_ intervals: [NativeSleepInterval]) -> String {
guard let minTime = intervals.map(\.fromTime).min(),
let maxTime = intervals.map(\.toTime).max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeActivityTargetRange(_ targets: [NativeActivityTarget]) -> String {
let times = targets.compactMap(\.healthValueTimestamp)
guard let minTime = times.min(),
let maxTime = times.max() else {
return "empty"
}
return "\(debugTimestamp(minTime))...\(debugTimestamp(maxTime))"
}
static func describeAnchors(_ anchors: [NativeHealthDataType: Data]) -> String {
anchors
.map { "type:\($0.key.rawValue)=\(describeAnchorData($0.value))" }
.sorted()
.joined(separator: ",")
}
static func describeAnchorData(_ data: Data) -> String {
"anchor=size:\(data.count),hash:\(data.stableDebugHash)"
}
static func describeActivityAnchors(_ anchors: AnchoredHealthActivityAnchorBundle) -> String {
[
"activeEnergy=\(describeAnchorData(anchors.activeEnergy))",
"exercise=\(describeAnchorData(anchors.exercise))",
"stand=\(describeAnchorData(anchors.stand))",
].joined(separator: ",")
}
static let debugDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}
private extension Data {
var stableDebugHash: String {
let hash = reduce(UInt64(14_695_981_039_346_656_037)) { result, byte in
(result ^ UInt64(byte)).multipliedReportingOverflow(by: 1_099_511_628_211).partialValue
}
return String(hash, radix: 16)
}
}
... ...
import Foundation
import HealthKit
struct SharedHealthAnchoredChanges {
let samples: [HKSample]
let newAnchor: HKQueryAnchor
let deletedObjectCount: Int
}
enum SharedHealthAnchoredQueryError: LocalizedError {
case missingAnchor
var errorDescription: String? { "HealthKit did not return a query anchor." }
}
final class SharedHealthAnchoredQueryReader {
private let healthStore: HKHealthStore
init(healthStore: HKHealthStore) {
self.healthStore = healthStore
}
func fetchChanges(
sampleType: HKSampleType,
anchor: HKQueryAnchor?,
initialStartDate: Date?
) async throws -> SharedHealthAnchoredChanges {
let predicate = initialStartDate.map {
HKQuery.predicateForSamples(withStart: $0, end: Date(), options: [])
}
return try await withCheckedThrowingContinuation { continuation in
let query = HKAnchoredObjectQuery(
type: sampleType,
predicate: predicate,
anchor: anchor,
limit: HKObjectQueryNoLimit
) { _, samples, deletedObjects, newAnchor, error in
if let error {
continuation.resume(throwing: error)
} else if let newAnchor {
continuation.resume(returning: SharedHealthAnchoredChanges(
samples: samples ?? [],
newAnchor: newAnchor,
deletedObjectCount: deletedObjects?.count ?? 0
))
} else {
continuation.resume(throwing: SharedHealthAnchoredQueryError.missingAnchor)
}
}
self.healthStore.execute(query)
}
}
}
enum SharedHealthAnchoredUploadSupport {
static func archive(_ anchor: HKQueryAnchor) throws -> Data {
try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
}
static func batches<Element>(_ values: [Element], size: Int) -> [[Element]] {
guard size > 0 else { return [values] }
return stride(from: 0, to: values.count, by: size).map {
Array(values[$0..<Swift.min($0 + size, values.count)])
}
}
}
@MainActor
final class SharedHealthAnchoredObserverController {
private let healthStore: HKHealthStore
private let observedTypes: Set<HKSampleType>
private let isLoggedIn: () -> Bool
private let isAuthorized: () async -> Bool
private let onChanges: (Set<String>) async -> Void
private let log: (String) -> Void
private var observerQuery: HKObserverQuery?
private var started = false
init(
healthStore: HKHealthStore,
observedTypes: Set<HKSampleType>,
isLoggedIn: @escaping () -> Bool,
isAuthorized: @escaping () async -> Bool,
onChanges: @escaping (Set<String>) async -> Void,
log: @escaping (String) -> Void
) {
self.healthStore = healthStore
self.observedTypes = observedTypes
self.isLoggedIn = isLoggedIn
self.isAuthorized = isAuthorized
self.onChanges = onChanges
self.log = log
}
func startIfNeeded() {
guard HKHealthStore.isHealthDataAvailable(), isLoggedIn() else { return }
Task { [weak self] in
guard let self else { return }
guard await isAuthorized() else {
log("observer.waitingForAuthorization")
return
}
startAuthorizedIfNeeded()
}
}
func restartAfterAuthorization() {
stop()
startIfNeeded()
}
func stop() {
if let observerQuery { healthStore.stop(observerQuery) }
observerQuery = nil
started = false
log("observer.stopped")
}
private func startAuthorizedIfNeeded() {
guard isLoggedIn() else { return }
observedTypes.forEach(enableBackgroundDelivery)
guard !started else { return }
let descriptors = observedTypes.map { HKQueryDescriptor(sampleType: $0, predicate: nil) }
let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] query, sampleTypes, completion, error in
Task { @MainActor in
guard let self else {
completion()
return
}
if let error {
self.log("observer.error error=\(error.localizedDescription)")
self.resetAfterError(query)
completion()
return
}
let identifiers = Set((sampleTypes ?? []).map(\.identifier))
self.log("observer.changed sources=\(identifiers.sorted().joined(separator: ","))")
await self.onChanges(identifiers)
completion()
}
}
observerQuery = query
started = true
healthStore.execute(query)
log("observer.started count=\(descriptors.count)")
}
private func resetAfterError(_ query: HKObserverQuery) {
guard observerQuery === query else { return }
healthStore.stop(query)
observerQuery = nil
started = false
log("observer.resetAfterError")
}
private func enableBackgroundDelivery(_ sampleType: HKSampleType) {
healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { [log] success, error in
if let error {
log("backgroundDelivery.failed source=\(sampleType.identifier) error=\(error.localizedDescription)")
} else {
log("backgroundDelivery source=\(sampleType.identifier) success=\(success)")
}
}
}
}
... ...
import Foundation
import HealthKit
/// Focused HealthKit query helper. It mirrors the original SwiftUI project data
/// coverage while avoiding dependencies on the old network and user modules.
final class HealthDataReader {
/// Low-level HealthKit query helper shared by the host API and anchored reader.
/// It contains no upload or anchor persistence behavior.
final class HealthKitQueryReader {
private let healthStore: HKHealthStore
init(healthStore: HKHealthStore) {
... ... @@ -435,7 +435,7 @@ final class HealthDataReader {
points.append(
NativeHealthDataPoint(
dataType: dataType,
time: latestEnd.timeIntervalSince1970,
time: Self.uploadTimeForDailyPoint(day: day, latestEnd: latestEnd).timeIntervalSince1970,
value: value
)
)
... ... @@ -480,7 +480,7 @@ final class HealthDataReader {
}
return NativeHealthDataPoint(
dataType: .stand,
time: latestEnd.timeIntervalSince1970,
time: Self.uploadTimeForDailyPoint(day: date, latestEnd: latestEnd).timeIntervalSince1970,
value: summary.appleStandHours.doubleValue(for: .count())
)
}
... ... @@ -492,6 +492,16 @@ final class HealthDataReader {
}
}
private static func uploadTimeForDailyPoint(day: Date, latestEnd: Date) -> Date {
let calendar = Calendar.current
let start = calendar.startOfDay(for: day)
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: start),
latestEnd >= nextDay else {
return latestEnd
}
return nextDay.addingTimeInterval(-1)
}
private func fetchSleepIntervals(startDate: Date, endDate: Date) async throws -> [NativeSleepInterval] {
guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
throw NativeHealthKitError.invalidType("sleepAnalysis")
... ... @@ -636,6 +646,7 @@ final class HealthDataReader {
}
}
healthStore.execute(query)
}
}
}
... ...
... ... @@ -6,16 +6,25 @@ import HealthKit
/// Responsibilities:
/// - request/read HealthKit permissions
/// - read the health data types used by the original SwiftUI app
/// - keep Watch complication values fresh in the shared App Group
/// - register background observers so HealthKit changes refresh local state
final class HealthKitService {
static let shared = HealthKitService()
private let healthStore = HKHealthStore()
private let syncStore = HealthSyncStateStore()
private lazy var reader = HealthDataReader(healthStore: healthStore)
private var observersStarted = false
private var observerQueries: [HKObserverQuery] = []
private lazy var reader = HealthKitQueryReader(healthStore: healthStore)
private lazy var anchoredReader = SharedHealthAnchoredQueryReader(healthStore: healthStore)
@MainActor private lazy var observerController = SharedHealthAnchoredObserverController(
healthStore: healthStore,
observedTypes: NativeHealthTypeCatalog.observedTypes,
isLoggedIn: { AppShared.shared.isLogin },
isAuthorized: { [weak self] in
await self?.authorizationRequestStatus() == .unnecessary
},
onChanges: { [weak self] identifiers in
await self?.handleObservedChanges(sampleTypeIdentifiers: identifiers)
},
log: { DebugLogger.debugLog("[ArchUploader] \($0)") }
)
private init() {}
... ... @@ -58,8 +67,8 @@ final class HealthKitService {
let endDate = Date()
let startDate = requestedStartDate
?? Calendar.current.date(byAdding: .year, value: -2, to: endDate)
?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)
?? Calendar.current.date(byAdding: .year, value: -1, to: endDate)
?? Date(timeInterval: -1 * 365 * 24 * 60 * 60, since: endDate)
let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
if await hasAnyReadableSample(
... ... @@ -116,53 +125,21 @@ final class HealthKitService {
}
func startBackgroundObserversIfNeeded() {
guard isHealthDataAvailable else { return }
// Enabling background delivery is safe to repeat and must be retried after
// authorization or a transient system failure.
NativeHealthTypeCatalog.observedTypes.forEach(enableBackgroundDelivery)
guard !observersStarted else { return }
observersStarted = true
for sampleType in NativeHealthTypeCatalog.observedTypes {
let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in
guard error == nil else {
completion()
return
}
Task {
await self?.handleObservedChange(sampleType)
completion()
}
}
observerQueries.append(query)
healthStore.execute(query)
Task { @MainActor [weak self] in
self?.observerController.startIfNeeded()
}
}
func performLocalSync() async -> NativeHealthSyncSummary {
guard isHealthDataAvailable else {
return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: Date(), endedAt: Date())
}
let startDate = earliestStartDate()
let endDate = Date()
do {
let summary = try await reader.collectRecentData(startDate: startDate, endDate: endDate)
NativeHealthDataType.allCases
.filter { $0 != .unknown }
.forEach { syncStore.save(date: endDate, for: $0) }
await refreshSharedWatchValues()
startBackgroundObserversIfNeeded()
return summary
} catch {
await refreshSharedWatchValues()
return NativeHealthSyncSummary(commonCount: 0, sleepCount: 0, startedAt: startDate, endedAt: endDate)
func restartBackgroundObserversAfterAuthorization() {
Task { @MainActor [weak self] in
self?.observerController.restartAfterAuthorization()
}
}
func refreshSharedWatchValues() async {
_ = WatchConnectivityService.shared.sendCommandMessage(AppGroupMessageKey.statusPulseRefresh)
func stopBackgroundObservers() {
Task { @MainActor [weak self] in
self?.observerController.stop()
}
}
func fetchHrvData(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
... ... @@ -229,75 +206,77 @@ final class HealthKitService {
try await reader.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
}
private func earliestStartDate() -> Date {
NativeHealthDataType.allCases
.filter { $0 != .unknown }
.map { syncStore.startDate(for: $0) }
.min() ?? Calendar.current.startOfDay(for: Date())
struct AnchoredChanges {
let samples: [HKSample]
let deletedObjectCount: Int
let newAnchor: HKQueryAnchor
let sourceIdentifier: String
}
private func enableBackgroundDelivery(for sampleType: HKSampleType) {
let frequency: HKUpdateFrequency = sampleType.identifier == HKQuantityTypeIdentifier.stepCount.rawValue
? .hourly
: .immediate
healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in
if let error {
print("HealthKit background delivery failed: \(sampleType.identifier), \(error.localizedDescription)")
} else {
print("HealthKit background delivery \(success ? "enabled" : "not enabled"): \(sampleType.identifier)")
}
func fetchAnchoredChanges(
for dataType: NativeHealthDataType,
sourceIdentifier overrideIdentifier: String? = nil,
anchor: HKQueryAnchor?,
initialStartDate: Date
) async throws -> AnchoredChanges {
guard let sampleType = anchoredSampleType(for: dataType, overrideIdentifier: overrideIdentifier) else {
throw NativeHealthKitError.invalidType("anchor source for \(dataType.rawValue)")
}
let changes = try await anchoredReader.fetchChanges(
sampleType: sampleType,
anchor: anchor,
initialStartDate: initialStartDate
)
return AnchoredChanges(
samples: changes.samples,
deletedObjectCount: changes.deletedObjectCount,
newAnchor: changes.newAnchor,
sourceIdentifier: sampleType.identifier
)
}
private func handleObservedChange(_ sampleType: HKSampleType) async {
switch sampleType.identifier {
case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue,
HKQuantityTypeIdentifier.stepCount.rawValue:
await refreshSharedWatchValues()
default:
break
private func anchoredSampleType(
for dataType: NativeHealthDataType,
overrideIdentifier: String?
) -> HKSampleType? {
if let overrideIdentifier {
if overrideIdentifier == HKCategoryTypeIdentifier.sleepAnalysis.rawValue {
return NativeHealthTypeCatalog.category(.sleepAnalysis)
}
if overrideIdentifier == HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue {
return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
}
return NativeHealthTypeCatalog.quantity(HKQuantityTypeIdentifier(rawValue: overrideIdentifier))
}
let uploadTypes: [NativeHealthDataType]
let includeActivityTarget: Bool
switch sampleType.identifier {
case HKQuantityTypeIdentifier.heartRate.rawValue:
uploadTypes = [.heartRate, .sleepingHeartRate]
includeActivityTarget = false
case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
uploadTypes = [.sleep, .sleepingHeartRate]
includeActivityTarget = false
default:
uploadTypes = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier).map { [$0] } ?? []
includeActivityTarget = [
HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
HKQuantityTypeIdentifier.appleStandTime.rawValue,
].contains(sampleType.identifier)
switch dataType {
case .hrv: return NativeHealthTypeCatalog.quantity(.heartRateVariabilitySDNN)
case .heartRate, .sleepingHeartRate: return NativeHealthTypeCatalog.quantity(.heartRate)
case .walkingHeartRate: return NativeHealthTypeCatalog.quantity(.walkingHeartRateAverage)
case .restingHeartRate: return NativeHealthTypeCatalog.quantity(.restingHeartRate)
case .oxygenSaturation: return NativeHealthTypeCatalog.quantity(.oxygenSaturation)
case .activeEnergy: return NativeHealthTypeCatalog.quantity(.activeEnergyBurned)
case .exercise: return NativeHealthTypeCatalog.quantity(.appleExerciseTime)
case .stand: return NativeHealthTypeCatalog.quantity(.appleStandTime)
case .steps: return NativeHealthTypeCatalog.quantity(.stepCount)
case .sleepingWristTemperature: return NativeHealthTypeCatalog.quantity(.appleSleepingWristTemperature)
case .respiratoryRate: return NativeHealthTypeCatalog.quantity(.respiratoryRate)
case .irregularHeartRhythm: return NativeHealthTypeCatalog.category(.irregularHeartRhythmEvent)
case .sleep: return NativeHealthTypeCatalog.category(.sleepAnalysis)
case .unknown: return nil
}
}
guard !uploadTypes.isEmpty || includeActivityTarget else { return }
let result = await NativeHealthDataUploader.shared.uploadObservedChange(
types: uploadTypes,
includeActivityTarget: includeActivityTarget,
observedSampleTypeIdentifier: sampleType.identifier,
service: self
private func handleObservedChanges(sampleTypeIdentifiers: Set<String>) async {
guard AppShared.shared.isLogin else { return }
await AnchoredHealthDataUploader.shared.uploadAllAfterObservedChange(
sampleTypeIdentifiers: sampleTypeIdentifiers
)
if result.success {
result.latestTimestamps.forEach { type, timestamp in
syncStore.save(date: Date(timeIntervalSince1970: timestamp), for: type)
}
}
}
func uploadActivityTargetAfterForeground() async {
func uploadHealthDataAfterForeground() async {
guard AppShared.shared.token?.isEmpty == false else { return }
_ = await NativeHealthDataUploader.shared.uploadObservedChange(
types: [],
includeActivityTarget: true,
service: self
)
_ = await AnchoredHealthDataUploader.shared.uploadAll()
}
}
... ... @@ -317,38 +296,3 @@ private final class HealthReadableDataProbeState: @unchecked Sendable {
return dataFound
}
}
private extension NativeHealthDataType {
init?(sampleTypeIdentifier: String) {
switch sampleTypeIdentifier {
case HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue:
self = .hrv
case HKQuantityTypeIdentifier.heartRate.rawValue:
self = .heartRate
case HKQuantityTypeIdentifier.stepCount.rawValue:
self = .steps
case HKQuantityTypeIdentifier.oxygenSaturation.rawValue:
self = .oxygenSaturation
case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
self = .activeEnergy
case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
self = .exercise
case HKQuantityTypeIdentifier.appleStandTime.rawValue:
self = .stand
case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
self = .walkingHeartRate
case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
self = .restingHeartRate
case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
self = .sleepingWristTemperature
case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
self = .respiratoryRate
case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
self = .sleep
case HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue:
self = .irregularHeartRhythm
default:
return nil
}
}
}
... ...
... ... @@ -18,6 +18,39 @@ enum NativeHealthKitError: LocalizedError {
}
}
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
static let sleepCorrectionLookbackHours: TimeInterval = 36
}
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 编解码失败"
}
}
}
/// Raw values match the original SwiftUI project server contract.
enum NativeHealthDataType: Int, Codable, CaseIterable, Hashable {
case unknown = 0
... ...
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"
}
}
}
... ... @@ -8,16 +8,6 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
self.service = service
}
func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void) {
// Apple Health authorization is system-managed, not URL based.
completion(.success(""))
}
func cancelHealthAppAuthorization() throws -> Bool {
// iOS does not let apps revoke HealthKit permission programmatically.
// Users must revoke access in Settings > Health > Data Access & Devices.
false
}
func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) {
Task {
... ... @@ -32,6 +22,9 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
if requestStatus == .shouldRequest {
authorization = HealthAuthorization(status: 0, hasData: false)
} else {
// `.unnecessary` means another authorization request is not needed;
// HealthKit does not reveal whether read access was granted or denied.
// Probe readable samples to preserve the existing status contract.
let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date())
?? Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)
let hasData = await service.hasAnyReadableData(startingAt: oneMonthAgo)
... ... @@ -39,8 +32,8 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
}
}
DebugLogger.log(
desc: "checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)"
DebugLogger.debugLog(
"checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)"
)
await MainActor.run {
completion(.success(authorization))
... ... @@ -49,21 +42,23 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
}
func requestHealthClientAuthorization(completion: @escaping (Result<Bool, any Error>) -> Void) {
DebugLogger.log(desc:"requestHealthClientAuthorization")
DebugLogger.debugLog("requestHealthClientAuthorization")
service.requestAuthorization { [service] success, error in
if let error {
DebugLogger.log(desc:"requestHealthClientAuthorization error: \(error)")
DebugLogger.debugLog("requestHealthClientAuthorization error: \(error)")
completion(.failure(error))
return
}
Task {
// HealthKit deliberately does not expose read authorization per type.
// A successful authorization request means the sheet completed; an
// empty store must not be treated as denied permission.
let granted = success
DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)")
DebugLogger.debugLog("requestHealthClientAuthorization granted: \(granted)")
if granted {
service.startBackgroundObserversIfNeeded()
await service.refreshSharedWatchValues()
_ = await NativeHealthDataUploader.shared.uploadAll(service: service)
service.restartBackgroundObserversAfterAuthorization()
_ = await AnchoredHealthDataUploader.shared.uploadAll()
}
completion(.success(granted))
}
... ... @@ -72,7 +67,7 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
func performHealthUpload(completion: @escaping (Result<HealthUploadResult, any Error>) -> Void) {
Task{
let summary = await NativeHealthDataUploader.shared.uploadAll(service: self.service)
let summary = await AnchoredHealthDataUploader.shared.uploadAll()
let result = HealthUploadResult(
commonUploadSuccess: summary.commonUploadSuccess,
sleepUploadSuccess: summary.sleepUploadSuccess,
... ... @@ -81,6 +76,21 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
completion(.success(result))
}
}
//MARK: -DEBUG
func getHealthServerAuthUrl(completion: @escaping (Result<String, Error>) -> Void) {
// Apple Health authorization is system-managed, not URL based.
completion(.success(""))
}
func cancelHealthAppAuthorization() throws -> Bool {
// iOS does not let apps revoke HealthKit permission programmatically.
// Users must revoke access in Settings > Health > Data Access & Devices.
false
}
func getDebugCurrentUploadedData() throws -> [HealthUploadDataPoint] {
[]
}
func fetchHrvData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthUploadDataPoint], Error>) -> Void) {
fetchCommon(startTime: startTime, endTime: endTime, service.fetchHrvData, completion: completion)
... ... @@ -160,7 +170,16 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
completion(.success(target.map {
HealthActivityTargetData(
move: $0.move.map(Int64.init),
stand: $0.stand.map(Int64.init)
stand: $0.stand.map(Int64.init),
// activityMoveMode: $0.activityMoveMode.map(Int64.init),
// activeEnergyBurned: $0.activeEnergyBurned,
// activeEnergyBurnedGoal: $0.activeEnergyBurnedGoal,
// appleMoveTime: $0.appleMoveTime,
// appleMoveTimeGoal: $0.appleMoveTimeGoal,
// appleExerciseTime: $0.appleExerciseTime,
// exerciseTimeGoal: $0.exerciseTimeGoal,
// appleStandHours: $0.appleStandHours,
// standHoursGoal: $0.standHoursGoal
)
}))
} catch {
... ...
... ... @@ -161,7 +161,7 @@ final class PlatformHostApiImpl: PlatformHostApi {
Task{
await AppShared.shared.reportDeviceInfo()
if await HealthKitService.shared.hasAnyReadableData() {
_ = await NativeHealthDataUploader.shared.uploadAll()
_ = await AnchoredHealthDataUploader.shared.uploadAll()
}
}
}
... ...
... ... @@ -22,7 +22,7 @@ struct RunnerApp: App {
appDelegate.setup()
HealthKitService.shared.startBackgroundObserversIfNeeded()
Task {
await HealthKitService.shared.uploadActivityTargetAfterForeground()
await HealthKitService.shared.uploadHealthDataAfterForeground()
}
}
}
... ...
... ... @@ -103,9 +103,6 @@ class MyController extends GetxController {
await loadWatchThemes();
}
void testAppleHealthUpload() {
Get.toNamed(Routes.APPLE_HEALTH_UPLOAD_TEST);
}
Future<void> toPremiumPage() async {
await Get.toNamed(Routes.PURCHASE, arguments: {
... ...
... ... @@ -68,13 +68,6 @@ class MyTab extends GetView<MyController> {
if (environmentConfig.isDebug) ...[
const SizedBox(height: 12),
_SettingsRow(
title: 'Apple Health Upload 测试',
onTap: () {
controller.testAppleHealthUpload();
},
),
const SizedBox(height: 12),
_SettingsRow(
title: 'Developer options',
onTap: () {
Get.toNamed(Routes.DEVELOPER_OPTIONS);
... ...
... ... @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/app_const.dart';
import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
... ... @@ -234,6 +235,13 @@ class CreateWatchThemeController extends GetxController {
agreedToSubmission.toggle();
}
void openSubmissionAgreement() {
Get.toNamed(
AppRoutes.webview,
parameters: {'url': AppConst.userSubmissionAgreement},
);
}
Future<void> saveCustomTheme() async {
if (!canSaveCustomTheme) {
return;
... ...
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -251,9 +252,24 @@ class _AgreementRow extends StatelessWidget {
: null,
),
SizedBox(width: 4),
Text(
context.l10n.watchThemeSubmissionAgreement,
style: TextStyle(
Text.rich(
TextSpan(
children: [
TextSpan(
text: context.l10n.watchThemeSubmissionAgreementPrefix,
),
TextSpan(
text: context.l10n.watchThemeSubmissionAgreementLink,
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 12,
),
recognizer: TapGestureRecognizer()
..onTap = controller.openSubmissionAgreement,
),
],
),
style: const TextStyle(
color: WatchThemeColors.textSecondary,
fontSize: 12,
),
... ...
... ... @@ -23,4 +23,6 @@ abstract final class AppConst {
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html';
static const String privacyPolicy =
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html';
static const String userSubmissionAgreement =
'https://cdn.doublefeel.cn/doublefeel/protocol/creator.html';
}
... ...
... ... @@ -649,6 +649,8 @@
"watchThemeName": "Theme Name",
"watchThemeNameMaxLength": "Up to 10 characters",
"watchThemeSubmissionAgreement": "I have read and agree to the User Submission Agreement",
"watchThemeSubmissionAgreementPrefix": "I have read and agree to the User ",
"watchThemeSubmissionAgreementLink": "Submission Agreement",
"watchThemeSaving": "Saving",
"watchThemeSaveTheme": "Save Theme",
"watchThemeExcellent": "Excellent",
... ... @@ -695,4 +697,4 @@
"feedbackSubmitSuccessTitle": "Feedback submitted successfully",
"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.",
"feedbackSubmitSuccessConfirm": "OK"
}
\ No newline at end of file
}
... ...
... ... @@ -1028,6 +1028,8 @@
"watchThemeName": "主题名称",
"watchThemeNameMaxLength": "最多10个字符",
"watchThemeSubmissionAgreement": "我已阅读并同意用户投稿协议",
"watchThemeSubmissionAgreementPrefix": "我已阅读并同意用户",
"watchThemeSubmissionAgreementLink": "投稿协议",
"watchThemeSaving": "保存中",
"watchThemeSaveTheme": "保存主题",
"watchThemeExcellent": "状态优秀",
... ...
... ... @@ -3807,6 +3807,18 @@ abstract class AppLocalizations {
/// **'我已阅读并同意用户投稿协议'**
String get watchThemeSubmissionAgreement;
/// No description provided for @watchThemeSubmissionAgreementPrefix.
///
/// In zh, this message translates to:
/// **'我已阅读并同意用户'**
String get watchThemeSubmissionAgreementPrefix;
/// No description provided for @watchThemeSubmissionAgreementLink.
///
/// In zh, this message translates to:
/// **'投稿协议'**
String get watchThemeSubmissionAgreementLink;
/// No description provided for @watchThemeSaving.
///
/// In zh, this message translates to:
... ...
... ... @@ -2135,6 +2135,13 @@ class AppLocalizationsEn extends AppLocalizations {
'I have read and agree to the User Submission Agreement';
@override
String get watchThemeSubmissionAgreementPrefix =>
'I have read and agree to the User ';
@override
String get watchThemeSubmissionAgreementLink => 'Submission Agreement';
@override
String get watchThemeSaving => 'Saving';
@override
... ...
... ... @@ -2031,6 +2031,12 @@ class AppLocalizationsZh extends AppLocalizations {
String get watchThemeSubmissionAgreement => '我已阅读并同意用户投稿协议';
@override
String get watchThemeSubmissionAgreementPrefix => '我已阅读并同意用户';
@override
String get watchThemeSubmissionAgreementLink => '投稿协议';
@override
String get watchThemeSaving => '保存中';
@override
... ...